| name | psique-workflow-clinic |
| description | Sesión - Mental health practice management platform with AI orchestration, WhatsApp automation, AFIP billing, and video consultations for Argentine psychology clinics |
| triggers | ["how do I set up Sesión for a psychology clinic","configure WhatsApp automation for patient reminders","integrate AFIP electronic invoicing in Sesión","set up Claude AI for clinical note summarization","implement video consultation workflow","configure patient journey pipeline stages","automate appointment scheduling with conflict detection","integrate Mercado Pago payment processing"] |
Sesión Workflow Clinic
Skill by ara.so — Devtools Skills collection.
Sesión is a comprehensive SaaS platform for psychology clinics in Argentina that unifies appointment scheduling, WhatsApp automation, AFIP-compliant billing, secure video consultations, and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6.
Architecture Overview
Sesión uses a microservices architecture with:
- Frontend: SvelteKit 5 with TypeScript and TailwindCSS
- Backend: NestJS microservices
- Database: PostgreSQL with Prisma ORM
- Cache: Redis for sessions and real-time presence
- Search: Elasticsearch for patient records
- Storage: MinIO for encrypted documents
- Messaging: Apache Kafka for event-driven workflows
- WhatsApp: Baileys library for WhatsApp Business integration
- Video: LiveKit for WebRTC video consultations
- AI: Anthropic Claude Opus 4.6 and Sonnet 4.6
- Payments: Stripe and Mercado Pago integration
Installation
Prerequisites
node --version
npm --version
docker --version
Clone and Install
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
npm install
Environment Configuration
Create .env file in project root:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/sesion"
# Redis
REDIS_URL="redis://localhost:6379"
# Anthropic AI
ANTHROPIC_API_KEY="sk-ant-..."
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
# WhatsApp (Baileys)
WHATSAPP_SESSION_PATH="./whatsapp-sessions"
WHATSAPP_WEBHOOK_SECRET="your-webhook-secret"
# LiveKit Video
LIVEKIT_API_KEY="your-livekit-api-key"
LIVEKIT_API_SECRET="your-livekit-api-secret"
LIVEKIT_URL="wss://your-livekit-server.com"
# AFIP (Argentina Tax Authority)
AFIP_CUIT="20123456789"
AFIP_CERTIFICATE_PATH="./certificates/afip-cert.pem"
AFIP_PRIVATE_KEY_PATH="./certificates/afip-key.pem"
AFIP_ENVIRONMENT="production" # or "homologacion" for testing
# Payment Gateways
MERCADOPAGO_ACCESS_TOKEN="APP_USR-..."
STRIPE_SECRET_KEY="sk_live_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# Application
JWT_SECRET="your-jwt-secret"
FRONTEND_URL="https://app.sesion.com.ar"
BACKEND_URL="https://api.sesion.com.ar"
Database Setup
npx prisma generate
npx prisma migrate deploy
npx prisma db seed
Start Services
npm run dev
npm run build
npm run start
docker-compose up -d
Core Module Usage
1. Appointment Scheduling Engine
Schema Definition (Prisma):
model Appointment {
id String @id @default(cuid())
patientId String
practitionerId String
sessionType SessionType
startTime DateTime
endTime DateTime
status AppointmentStatus
roomId String?
isVirtual Boolean @default(false)
notes String?
patient Patient @relation(fields: [patientId], references: [id])
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
room Room? @relation(fields: [roomId], references: [id])
@@index([practitionerId, startTime])
@@index([patientId])
}
enum SessionType {
INDIVIDUAL
COUPLES
FAMILY
EVALUATION
FOLLOWUP
}
enum AppointmentStatus {
SCHEDULED
CONFIRMED
CANCELLED
COMPLETED
NO_SHOW
}
Creating Appointments with Conflict Detection:
import { Injectable, ConflictException } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class AppointmentService {
constructor(
private prisma: PrismaService,
private eventEmitter: EventEmitter2
) {}
async createAppointment(data: CreateAppointmentDto) {
const conflicts = await this.findConflicts(
data.practitionerId,
data.startTime,
data.endTime,
data.roomId
);
if (conflicts.length > 0) {
throw new ConflictException({
message: 'Scheduling conflict detected',
conflicts: conflicts,
suggestions: .(data)
});
}
appointment = ...({
: {
: data.,
: data.,
: data.,
: data.,
: data.,
: data.,
: data.,
:
},
: {
: ,
:
}
});
..(, appointment);
appointment;
}
() {
...({
: {
: [
{
: [
{ practitionerId },
{ : roomId || }
]
},
{
: { : [, ] }
},
{
: [
{
: [
{ : { : startTime } },
{ : { : startTime } }
]
},
{
: [
{ : { : endTime } },
{ : { : endTime } }
]
},
{
: [
{ : { : startTime } },
{ : { : endTime } }
]
}
]
}
]
}
});
}
() {
duration = data..() - data..();
searchStart = (data.);
searchStart.(searchStart.() - );
searchEnd = (data.);
searchEnd.(searchEnd.() + );
allSlots = .(
data.,
searchStart,
searchEnd,
duration
);
allSlots.(, );
}
}
2. WhatsApp Automation with Baileys
WhatsApp Service Implementation:
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WAMessage
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class WhatsAppService {
private sock: any;
private qrCode: string;
async initialize() {
const { state, saveCreds } = await useMultiFileAuthState(
process.env.WHATSAPP_SESSION_PATH
);
this.sock = makeWASocket({
auth: state,
printQRInTerminal: true
});
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
this.qrCode = qr;
}
(connection === ) {
shouldReconnect = (lastDisconnect?. )?.
?. !== .;
(shouldReconnect) {
.();
}
}
});
...(, saveCreds);
...(, ..());
}
()
() {
patient = appointment.;
practitioner = appointment.;
message = .(appointment);
.(patient., message, {
: [
{ : , : { : } },
{ : , : { : } }
]
});
}
()
() {
message = .();
.(appointment.., message);
}
() {
jid = ;
(options?.) {
..(jid, {
text,
: ,
: options.,
:
});
} {
..(jid, { text });
}
}
() {
message = m.[];
(!message.) ;
text = message.. ||
message..?. || ;
crisisKeywords = [
, , ,
, ,
];
isCrisis = crisisKeywords.(
text.().(keyword)
);
(isCrisis) {
.(message);
}
(message..) {
buttonId = message...;
.(buttonId, message);
}
}
(: ): {
.(, {
: ,
: ,
:
}).(date);
}
}
3. AFIP Electronic Invoicing
Invoice Generation Service:
import { Injectable } from '@nestjs/common';
import { readFileSync } from 'fs';
import { Afip } from '@afipsdk/afip.js';
@Injectable()
export class AfipBillingService {
private afip: Afip;
constructor() {
this.afip = new Afip({
CUIT: process.env.AFIP_CUIT,
cert: readFileSync(process.env.AFIP_CERTIFICATE_PATH, 'utf8'),
key: readFileSync(process.env.AFIP_PRIVATE_KEY_PATH, 'utf8'),
production: process.env.AFIP_ENVIRONMENT === 'production'
});
}
async generateInvoice(sessionData: GenerateInvoiceDto) {
const invoiceType = this.determineInvoiceType(sessionData.);
lastVoucher = ...(
sessionData.,
invoiceType
);
invoiceNumber = lastVoucher + ;
invoiceData = {
: ,
: sessionData.,
: invoiceType,
: ,
: ,
: sessionData.,
: invoiceNumber,
: invoiceNumber,
: .( ()),
: .(sessionData.),
: .(sessionData.),
: .(sessionData.),
: sessionData.,
: ,
: sessionData.,
: ,
: ,
: ,
: ,
:
};
result = ...(invoiceData);
invoice = ...({
: {
: result.,
: result.,
: invoiceNumber,
: invoiceType,
: sessionData.,
: sessionData.,
: sessionData.,
: sessionData.,
: result
}
});
..(, invoice);
invoice;
}
(: ): {
mapping = {
: ,
: ,
:
};
mapping[fiscalCategory] || ;
}
(: ): {
date.().()[].(, );
}
(: ): <> {
invoice = ...({
: { : invoiceId },
: { : , : }
});
pdf = .(invoice);
pdf;
}
}
4. Claude AI Integration
AI Orchestration Service:
import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
@Injectable()
export class ClaudeOrchestrationService {
private anthropic: Anthropic;
private opusModel = process.env.CLAUDE_OPUS_MODEL;
private sonnetModel = process.env.CLAUDE_SONNET_MODEL;
constructor() {
this.anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
}
async summarizeClinicalNotes(sessionNotes: string, patientHistory?: string) {
const prompt = `
Sos un psicólogo clínico argentino especializado en resumir notas de sesión.
${patientHistory ? `Contexto del paciente:\n${patientHistory}\n\n` : ''}
Notas de la sesión actual:
${sessionNotes}
Generá un resumen estructurado siguiendo este formato:
1. Presentación del paciente
2. Temas principales abordados
3. Intervenciones realizadas
4. Evolución observada
5. Plan terapéutico para próxima sesión
Usá lenguaje técnico apropiado pero claro.
`.();
message = ...({
: .,
: ,
: ,
: ,
: [
{ : , : prompt }
]
});
message.[].;
}
() {
systemPrompt = .();
message = ...({
: .,
: ,
: ,
: systemPrompt,
: [
{ : , : .(query, context) }
]
});
.({
: .,
: context.,
: message..,
: message..,
: .(message.)
});
message.[].;
}
() {
patient = ...({
: { : patientId },
: {
: {
: { : },
: { : }
},
:
}
});
sessionHistory = patient.
.( )
.();
prompt = .();
message = ...({
: .,
: ,
: ,
: [
{ : , : prompt }
]
});
{
: message.[].,
: .(message.[].)
};
}
(: , : ): {
.();
}
(: ): {
inputCostPerMillion = ;
outputCostPerMillion = ;
(
(usage. / ) * inputCostPerMillion +
(usage. / ) * outputCostPerMillion
);
}
(: ): {
;
}
}
5. LiveKit Video Consultation
Video Service Implementation:
import { Injectable } from '@nestjs/common';
import { AccessToken } from 'livekit-server-sdk';
@Injectable()
export class VideoService {
private apiKey = process.env.LIVEKIT_API_KEY;
private apiSecret = process.env.LIVEKIT_API_SECRET;
private livekitUrl = process.env.LIVEKIT_URL;
async createVideoSession(appointmentId: string) {
const appointment = await this.prisma.appointment.findUnique({
where: { id: appointmentId },
include: { patient: true, practitioner: true }
});
if (!appointment.isVirtual) {
throw new Error('Appointment is not configured for virtual session');
}
const roomName = `session-${appointmentId}`;
waitingRoom = ...({
: {
appointmentId,
roomName,
: ,
: ()
}
});
{
roomName,
: waitingRoom.,
: .(
roomName,
appointment..,
appointment..,
{ : , : , : }
),
: .(
roomName,
appointment..,
appointment..,
{ : , : , : }
),
: .
};
}
(
: ,
: ,
: ,
:
): {
token = (., ., {
identity,
name
});
token.({
: roomName,
: ,
: permissions.,
: permissions.,
: ,
:
});
token.();
}
() {
waitingRoom = ...({
: { : waitingRoomId },
: { : , : () }
});
..(, waitingRoom);
waitingRoom;
}
() {
(!consent) {
();
}
appointment = ...({
: { : appointmentId }
});
...({
: {
appointmentId,
: ,
: (),
:
}
});
}
}
Frontend Implementation (SvelteKit 5)
Appointment Calendar Component:
<!-- routes/agenda/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { Calendar } from '$lib/components/calendar';
import { appointmentStore } from '$lib/stores/appointments';
let appointments = $state([]);
let selectedDate = $state(new Date());
let showConflicts = $state(false);
onMount(async () => {
await loadAppointments();
});
async function loadAppointments() {
const response = await fetch('/api/appointments', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
appointments = await response.json();
}
async function createAppointment(data: any) {
try {
const response = await fetch('/api/appointments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
if (error.conflicts) {
showConflicts = true;
// Show conflict resolution UI
return;
}
throw new Error(error.message);
}
await loadAppointments();
} catch (err) {
console.error('Error creating appointment:', err);
}
}
</script>
<div class="agenda-container">
<header class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Agenda</h1>
<button
onclick={() => createAppointment({ /* ... */ })}
class="btn-primary"
>
Nuevo Turno
</button>
</header>
<Calendar
{appointments}
{selectedDate}
onDateChange={(date) => selectedDate = date}
onAppointmentClick={(apt) => /* handle click */}
/>
{#if showConflicts}
<ConflictResolutionModal
onClose={() => showConflicts = false}
/>
{/if}
</div>
AI Assistant Chat Interface:
<!-- lib/components/AIAssistant.svelte -->
<script lang="ts">
let messages = $state([]);
let input = $state('');
let isLoading = $state(false);
async function sendMessage() {
if (!input.trim()) return;
const userMessage = { role: 'user', content: input };
messages = [...messages, userMessage];
input = '';
isLoading = true;
try {
const response = await fetch('/api/ai/assistant', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
query: userMessage.content,
context: {
practitionerId: user.id,
activePatients: patientCount,
nextAppointment: nextAppointment
}
})
});
const data = await response.json();
messages = [...messages, { role: 'assistant', content: data.response }];
} catch (err) {
console.error('AI Assistant error:', err);
} finally {
isLoading = false;
}
}
</script>
<div class="ai-assistant-panel">
<div class="messages-container">
{#each messages as message}
<div class="message message-{message.role}">
{message.content}
</div>
{/each}
{#if isLoading}
<div class="message message-assistant loading">
<span class="animate-pulse">Pensando...</span>
</div>
{/if}
</div>
<form onsubmit={sendMessage} class="input-form">
<input
type="text"
bind:value={input}
placeholder="Preguntame algo sobre tus pacientes..."
class="input-field"
/>
<button type="submit" class="btn-send">Enviar</button>
</form>
</div>
Configuration
Prisma Schema Essentials
// 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
fullName String
cuit String @unique
fiscalCategory String
specialization String[]
licenseNumber String
appointments Appointment[]
patients Patient[]
createdAt DateTime @default(now())
}
model Patient {
id String @id @default(cuid())