| name | sesion-workflow-clinic |
| description | Mental health practice management platform with AI-powered scheduling, WhatsApp automation, AFIP billing, and video consultations for Argentine clinics |
| triggers | ["how do I integrate Sesión into my psychology clinic","set up WhatsApp automation for patient appointments","configure AFIP electronic invoicing in Sesión","implement Claude AI for clinical note summarization","create appointment scheduling with Sesión","build video consultation feature with Sesión","configure Mercado Pago payment integration","set up patient journey pipeline in Sesión"] |
Sesión Workflow Clinic
Skill by ara.so — Devtools Skills collection.
Sesión is a comprehensive mental health practice orchestration platform designed for psychology clinics in Argentina. It unifies appointment scheduling, automated WhatsApp messaging, AFIP-compliant billing, secure video consultations, and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6.
Tech Stack
- Frontend: SvelteKit 5 + Svelte 5, TailwindCSS
- Backend: NestJS, Prisma ORM
- Database: PostgreSQL (relational), Redis (caching), Elasticsearch (search)
- AI: Anthropic Claude Opus 4.6 & Sonnet 4.6
- Video: LiveKit (WebRTC)
- Messaging: Baileys (WhatsApp automation)
- Payments: Stripe, Mercado Pago
- Language: TypeScript
Installation
Prerequisites
node >= 18.0.0
postgresql >= 14
redis >= 6.2
docker >= 20.10 (optional but recommended)
Clone and Install
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
npm install
cp .env.example .env
Environment Configuration
DATABASE_URL="postgresql://user:password@localhost:5432/sesion"
REDIS_URL="redis://localhost:6379"
ANTHROPIC_API_KEY=your_anthropic_key_here
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
WHATSAPP_SESSION_PATH="./whatsapp-session"
WHATSAPP_WEBHOOK_SECRET=your_webhook_secret
LIVEKIT_API_KEY=your_livekit_key
LIVEKIT_API_SECRET=your_livekit_secret
LIVEKIT_WS_URL="wss://your-livekit-instance.com"
STRIPE_SECRET_KEY=your_stripe_key
MERCADOPAGO_ACCESS_TOKEN=your_mercadopago_token
AFIP_CUIT=your_cuit_number
AFIP_CERT_PATH="./certs/afip-cert.pem"
AFIP_KEY_PATH="./certs/afip-key.pem"
Database Setup
npx prisma migrate dev
npx prisma generate
npm run db:seed
Start Development Server
npm run dev:api
npm run dev:web
docker-compose up -d
Core Modules
1. Appointment Scheduling
Prisma Schema (appointments):
model Appointment {
id String @id @default(cuid())
patientId String
practitionerId String
startTime DateTime
endTime DateTime
sessionType SessionType
status AppointmentStatus @default(SCHEDULED)
roomId String?
notes String?
patient Patient @relation(fields: [patientId], references: [id])
practitioner Practitioner @relation(fields: [practitionerId], references: [id])
room Room? @relation(fields: [roomId], references: [id])
@@index([patientId, startTime])
@@index([practitionerId, startTime])
}
enum SessionType {
PRESENCIAL
VIRTUAL
EVALUACION
PAREJA
FAMILIAR
}
enum AppointmentStatus {
SCHEDULED
CONFIRMED
IN_PROGRESS
COMPLETED
CANCELLED
NO_SHOW
}
Creating Appointments (NestJS Service):
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
@Injectable()
export class AppointmentsService {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsAppService,
) {}
async createAppointment(data: CreateAppointmentDto) {
const conflicts = await this.prisma.appointment.findMany({
where: {
practitionerId: data.practitionerId,
status: { not: 'CANCELLED' },
OR: [
{
AND: [
{ startTime: { lte: data.startTime } },
{ endTime: { gt: data. } },
],
},
{
: [
{ : { : data. } },
{ : { : data. } },
],
},
],
},
});
(conflicts. > ) {
();
}
appointment = ...({
: {
...data,
: ,
},
: {
: ,
: ,
},
});
..(appointment);
appointment;
}
() {
startOfDay = (date);
startOfDay.(, , , );
endOfDay = (date);
endOfDay.(, , , );
existingAppointments = ...({
: {
practitionerId,
: { : startOfDay, : endOfDay },
: { : },
},
: { : },
});
workingHours = { : , : };
: [] = [];
currentTime = (date);
currentTime.(workingHours., , , );
endTime = (date);
endTime.(workingHours., , , );
(currentTime < endTime) {
slotEnd = (currentTime.() + duration * );
hasConflict = existingAppointments.( {
currentTime < apt. && slotEnd > apt.;
});
(!hasConflict) {
slots.( (currentTime));
}
currentTime = (currentTime.() + * );
}
slots;
}
}
SvelteKit Appointment Scheduler:
<!-- src/routes/appointments/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import type { Appointment } from '$lib/types';
let selectedDate = $state(new Date());
let selectedPractitioner = $state('');
let availableSlots = $state<Date[]>([]);
let loading = $state(false);
async function loadAvailableSlots() {
loading = true;
try {
const response = await fetch('/api/appointments/available-slots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
practitionerId: selectedPractitioner,
date: selectedDate.toISOString(),
duration: 45,
}),
});
availableSlots = await response.json();
} finally {
loading = false;
}
}
async function bookAppointment(slot: Date) {
const response = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
practitionerId: selectedPractitioner,
patientId: $page.data.currentPatient.id,
startTime: slot,
endTime: new Date(slot.getTime() + 45 * 60000),
sessionType: 'VIRTUAL',
}),
});
if (response.ok) {
alert('Cita agendada correctamente');
loadAvailableSlots();
}
}
$effect(() => {
if (selectedPractitioner && selectedDate) {
loadAvailableSlots();
}
});
</script>
<div class="appointment-scheduler">
<h2 class="text-2xl font-bold mb-4">Agendar Sesión</h2>
<input
type="date"
bind:value={selectedDate}
class="border rounded px-4 py-2 mb-4"
/>
{#if loading}
<p class="text-gray-500">Cargando horarios disponibles...</p>
{:else if availableSlots.length === 0}
<p class="text-gray-500">No hay horarios disponibles para esta fecha</p>
{:else}
<div class="grid grid-cols-4 gap-2">
{#each availableSlots as slot}
<button
onclick={() => bookAppointment(slot)}
class="border border-blue-500 text-blue-500 hover:bg-blue-500 hover:text-white rounded px-4 py-2 transition"
>
{slot.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })}
</button>
{/each}
</div>
{/if}
</div>
2. WhatsApp Automation
WhatsApp Service with Baileys:
import { Injectable } from '@nestjs/common';
import makeWASocket, {
useMultiFileAuthState,
DisconnectReason,
MessageType,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
@Injectable()
export class WhatsAppService {
private socket: any;
private sessionPath = process.env.WHATSAPP_SESSION_PATH || './wa-session';
async initialize() {
const { state, saveCreds } = await useMultiFileAuthState(this.sessionPath);
this.socket = makeWASocket({
auth: state,
printQRInTerminal: true,
});
this.socket.ev.on('creds.update', saveCreds);
this.socket.ev.on('connection.update', () => {
{ connection, lastDisconnect } = update;
(connection === ) {
shouldReconnect = (lastDisconnect?. )?.?. !== .;
(shouldReconnect) {
.();
}
}
});
...(, ({ messages }) => {
.(messages);
});
}
() {
patient = appointment.;
message = +
+
+
+
+
;
..(
,
{ : message },
);
}
() {
patient = appointment.;
message = ;
..(
,
{ : message },
);
}
() {
( msg messages) {
(msg..) ;
text = msg.?. || msg.?.?.;
= msg..;
crisisKeywords = [, , , ];
(crisisKeywords.( text?.().(keyword))) {
.(, text);
}
(text?.() === || text?.() === ) {
.();
}
}
}
() {
.();
}
() {
phone = phoneNumber.(, );
...({
: {
: { : phone },
: ,
: { : () },
},
: { : },
});
}
}
Automated Reminder Cron Job:
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { WhatsAppService } from '../whatsapp/whatsapp.service';
@Injectable()
export class AppointmentsCronService {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsAppService,
) {}
@Cron(CronExpression.EVERY_HOUR)
async send24HourReminders() {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(dayAfterTomorrow.() + );
appointments = ...({
: {
: { : tomorrow, : dayAfterTomorrow },
: { : [, ] },
: ,
},
: { : , : },
});
( apt appointments) {
..(apt);
...({
: { : apt. },
: { : },
});
}
}
()
() {
now = ();
twoHoursLater = (now.() + * * * );
appointments = ...({
: {
: { : now, : twoHoursLater },
: { : [, ] },
: ,
},
: { : },
});
( apt appointments) {
..(
apt..,
,
);
...({
: { : apt. },
: { : },
});
}
}
}
3. AFIP Electronic Invoicing
AFIP Integration Service:
import { Injectable } from '@nestjs/common';
import Afip from '@afipsdk/afip.js';
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: process.env.AFIP_CERT_PATH,
key: process.env.AFIP_KEY_PATH,
production: process.env.NODE_ENV === 'production',
});
}
async generateInvoice(appointmentId: string) {
const appointment = await this.prisma.appointment.findUnique({
: { : appointmentId },
: { : , : },
});
(!appointment) ();
lastInvoice = ...(
,
,
);
invoiceData = {
: ,
: ,
: ,
: ,
: ,
: appointment.. || ,
: lastInvoice + ,
: lastInvoice + ,
: .( ()),
: appointment.,
: ,
: appointment.,
: ,
: ,
: ,
: ,
: ,
};
result = ...(invoiceData);
invoice = ...({
: {
: appointment.,
: appointment.,
: appointment.,
: ,
: result.,
: (result.),
: appointment.,
: ,
: ,
},
});
invoice;
}
(: ): {
year = date.();
month = (date.() + ).(, );
day = (date.()).(, );
();
}
() {
startDate = (year, month - , );
endDate = (year, month, , , , );
invoices = ...({
: {
practitionerId,
: { : startDate, : endDate },
},
: { : },
});
totalIncome = invoices.( sum + inv., );
sessionCount = invoices.;
{
totalIncome,
sessionCount,
invoices,
: .(totalIncome, practitionerId),
};
}
() {
{
: income * ,
: income * ,
: income * ,
};
}
}
4. AI Clinical Assistant (Claude Integration)
AI Orchestration Service:
import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
@Injectable()
export class ClaudeService {
private opusClient: Anthropic;
private sonnetClient: Anthropic;
constructor() {
const apiKey = process.env.ANTHROPIC_API_KEY;
this.opusClient = new Anthropic({ apiKey });
this.sonnetClient = new Anthropic({ apiKey });
}
async summarizeClinicalNotes(sessionNotes: string, patientHistory?: string) {
const prompt = `Eres un asistente clínico para psicólogos en Argentina.
Resume las siguientes notas de sesión de manera profesional y estructurada:
${sessionNotes}
${patientHistory ? `Historial previo del paciente:\n${patientHistory}` : ''}
Proporciona un resumen que incluya:
1. Motivo de consulta principal
2. Observaciones clínicas relevantes
3. Intervenciones realizadas
4. Plan terapéutico sugerido
5. Próximos pasos
Usa terminología profesional argentina y mantén la confidencialidad.`;
message = ...({
: process.. || ,
: ,
: [{ : , : prompt }],
});
message.[]. === ? message.[]. : ;
}
() {
appointment = ...({
: { : appointmentId },
: {
: {
: {
: {
: { : },
: { : },
: ,
: { : },
},
},
},
},
});
historyContext = appointment..
.( apt.?.)
.();
.(
appointment. || ,
historyContext,
);
}
() {
message = ...({
: process.. || ,
: ,
: [
{
: ,
: ,
},
],
});
message.[]. === ? message.[]. : ;
}
() {
patient = ...({
: { : patientId },
: {
: {
: { : },
: { : },
},
},
});
clinicalHistory = patient.
.( ({
: apt.,
: apt.?.,
: apt.?.,
}))
.( apt.);
prompt = ;
message = ...({
: process.. || ,
: ,
: [{ : , : prompt }],
});
responseText = message.[]. === ? message.[]. : ;
.(responseText);
}
}
SvelteKit AI Assistant Interface:
<!-- src/routes/ai-assistant/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
let query = $state('');
let conversation = $state<Array<{ role: string; content: string }>>([]);
let loading = $state(false);
async function askAssistant() {
if (!query.trim()) return;
conversation.push({ role: 'user', content: query });
loading = true;
try {
const response = await fetch('/api/ai/assistant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const data = await response.json();
conversation.push({ role: 'assistant', content: data.response });
query = '';
} finally {
loading = false;
}
}
</script>
<div class="ai-assistant max-w-4xl mx-auto p-6">
<h2 class="text-3xl font-bold mb-6">Asistente Clínico IA</h2>
<div class="conversation-history bg-gray-50 rounded-lg p-4 mb-4 h-96 overflow-y-auto">
{#each conversation as msg}
<div class="message mb-4 {msg.role === 'user' ? 'text-right' : 'text-left'}">
<div class="inline-block max-w-xs {msg.role === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-200'} rounded-lg px-4 py-2">
{msg.content}
</div>
</div>
{/each}
{#if loading}
<div class="text-gray-500 italic">Pensando...</div>
{/if}
</div>
<form onsubmit|preventDefault={askAssistant} class="flex gap-2">
<input
bind:value={query}
placeholder="Pregunta algo al asistente..."
class="flex-1 border rounded-lg px-4 py-2"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !query.trim()}
class="bg-blue-500 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
Enviar
</button>
</form>
</div>
5. Video Consultations (LiveKit)
LiveKit Room Service:
import { Injectable } from '@nestjs/common';
import { AccessToken } from 'livekit-server-sdk';
@Injectable()
export class LiveKitService {
private apiKey = process.env.LIVEKIT_API_KEY;
private apiSecret = process.env.LIVEKIT_API_SECRET;
private wsUrl = process.env.LIVEKIT_WS_URL;
async createRoomToken(roomName: string, participantName: string, metadata?: any) {
const token = new AccessToken(this.apiKey, this.apiSecret, {
identity: participantName,
metadata: JSON.stringify(metadata),
});
token.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: true,
canPublishData: ,
});
{
: token.(),
: .,
};
}
() {
appointment = .