| name | sesion-clinic-workflow |
| description | Mental health practice management platform with intelligent scheduling, WhatsApp automation, AFIP billing, video consultations, and Claude AI orchestration for Argentine psychologists |
| triggers | ["set up Sesión clinic workflow platform","integrate WhatsApp automation for patient appointments","configure AFIP electronic invoicing for psychology practice","implement Claude AI for clinical note summarization","build video consultation with LiveKit integration","create automated appointment reminders via WhatsApp","configure MercadoPago payment processing for sessions","set up multi-practitioner scheduling system"] |
Sesión Clinic Workflow Platform
Skill by ara.so — Devtools Skills collection.
Sesión is a comprehensive SaaS platform for psychology clinics in Argentina that orchestrates appointment scheduling, automated WhatsApp messaging via Baileys, AFIP-compliant electronic invoicing, secure LiveKit video consultations, and AI-powered clinical assistance using Claude Opus 4.6 and Sonnet 4.6. Built with NestJS backend, SvelteKit 5 frontend, Prisma ORM, and designed for Argentine healthcare compliance.
Installation & Setup
Prerequisites
node >= 20.x
pnpm >= 8.x
postgres >= 15.x
redis >= 7.x
Clone and Install
git clone https://github.com/fahad-hamid/psique-workflow-clinic.git
cd psique-workflow-clinic
pnpm install
cp .env.example .env
Core Environment Configuration
DATABASE_URL="postgresql://user:password@localhost:5432/sesion_db"
REDIS_URL="redis://localhost:6379"
ANTHROPIC_API_KEY="your_anthropic_api_key"
CLAUDE_OPUS_MODEL="claude-opus-4.6"
CLAUDE_SONNET_MODEL="claude-sonnet-4.6"
WHATSAPP_SESSION_PATH="./whatsapp-sessions"
WHATSAPP_WEBHOOK_SECRET="your_webhook_secret"
AFIP_CUIT="your_clinic_cuit"
AFIP_CERTIFICATE_PATH="./certs/afip-cert.pem"
AFIP_PRIVATE_KEY_PATH="./certs/afip-key.pem"
AFIP_PRODUCTION_MODE="false"
MERCADOPAGO_ACCESS_TOKEN="your_mp_access_token"
STRIPE_SECRET_KEY="your_stripe_secret_key"
LIVEKIT_API_KEY="your_livekit_api_key"
LIVEKIT_API_SECRET="your_livekit_api_secret"
LIVEKIT_WS_URL="wss://your-livekit-server.com"
JWT_SECRET="your_jwt_secret"
APP_URL="http://localhost:5173"
API_URL="http://localhost:3000"
Database Migration
pnpm prisma generate
pnpm prisma migrate deploy
pnpm prisma db seed
Start Development Servers
cd apps/backend
pnpm dev
cd apps/frontend
pnpm dev
Project Structure
psique-workflow-clinic/
├── apps/
│ ├── backend/ # NestJS API server
│ │ ├── src/
│ │ │ ├── agenda/ # Scheduling module
│ │ │ ├── whatsapp/ # Baileys integration
│ │ │ ├── billing/ # AFIP invoicing
│ │ │ ├── video/ # LiveKit module
│ │ │ ├── ai/ # Claude orchestration
│ │ │ └── patients/ # Patient management
│ │ └── prisma/
│ │ └── schema.prisma
│ └── frontend/ # SvelteKit UI
│ ├── src/
│ │ ├── routes/ # File-based routing
│ │ ├── lib/ # Shared components
│ │ └── stores/ # Svelte stores
└── packages/
├── shared/ # Shared types & utils
└── config/ # Shared config
Core Module Usage
1. Intelligent Appointment Scheduling
Create Appointment (Backend - NestJS)
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
@Injectable()
export class AgendaService {
constructor(
private prisma: PrismaService,
private whatsapp: WhatsappService
) {}
async createAppointment(data: {
patientId: string;
practitionerId: string;
startTime: Date;
duration: number; // minutes
type: 'PRESENCIAL' | 'VIRTUAL' | 'EVALUACION';
}) {
const conflict = await this.prisma.appointment.findFirst({
where: {
practitionerId: data.practitionerId,
status: { not: 'CANCELLED' },
: [
{
: {
: data.,
},
: {
: data.,
},
},
],
},
});
(conflict) {
();
}
appointment = ...({
: {
...data,
: (data..() + data. * ),
: ,
},
: {
: ,
: ,
},
});
..(appointment);
appointment;
}
() {
dayStart = (date.(, , , ));
dayEnd = (date.(, , , ));
existingAppointments = ...({
: {
practitionerId,
: { : dayStart, : dayEnd },
: { : },
},
: { : },
});
workingHours = ...({
: {
practitionerId,
: date.(),
},
});
(!workingHours) [];
slots = [];
currentTime = (
date.(
workingHours.,
workingHours.,
,
)
);
endTime = (
date.(workingHours., workingHours., , )
);
(currentTime < endTime) {
slotEnd = (currentTime.() + duration * );
hasConflict = existingAppointments.(
currentTime < (apt.) &&
slotEnd > (apt.)
);
(!hasConflict) {
slots.({
: (currentTime),
: slotEnd,
});
}
currentTime = slotEnd;
}
slots;
}
}
Frontend Scheduling Component (Svelte 5)
<!-- apps/frontend/src/routes/agenda/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { Appointment } from '$lib/types';
let appointments = $state<Appointment[]>([]);
let selectedDate = $state(new Date());
let loading = $state(false);
async function loadAppointments() {
loading = true;
const response = await fetch(`/api/appointments?date=${selectedDate.toISOString()}`);
appointments = await response.json();
loading = false;
}
async function createAppointment(data: {
patientId: string;
startTime: Date;
duration: number;
}) {
const response = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
await loadAppointments();
}
}
onMount(() => {
loadAppointments();
});
</script>
<div class="agenda-container">
<h1>Agenda Inteligente</h1>
<input
type="date"
bind:value={selectedDate}
onchange={loadAppointments}
/>
{#if loading}
<div class="spinner">Cargando...</div>
{:else}
<div class="appointments-list">
{#each appointments as appointment}
<div class="appointment-card">
<div class="time">
{new Date(appointment.startTime).toLocaleTimeString('es-AR', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div class="patient-name">{appointment.patient.name}</div>
<div class="type-badge" class:virtual={appointment.type === 'VIRTUAL'}>
{appointment.type}
</div>
</div>
{/each}
</div>
{/if}
</div>
2. WhatsApp Automation with Baileys
import { Injectable, Logger } from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
WAMessage,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
@Injectable()
export class WhatsappService {
private readonly logger = new Logger(WhatsappService.name);
private sock: any;
private connected = false;
async initialize() {
const { state, saveCreds } = await useMultiFileAuthState(
process.env.WHATSAPP_SESSION_PATH || './whatsapp-sessions'
);
this.sock = makeWASocket({
auth: state,
printQRInTerminal: true,
});
this.sock.ev.on('creds.update', saveCreds);
...(, {
{ connection, lastDisconnect } = update;
(connection === ) {
shouldReconnect =
(lastDisconnect?. )?.?. !==
.;
(shouldReconnect) {
.();
}
} (connection === ) {
..();
. = ;
}
});
...(, ({ messages }) => {
.(messages[]);
});
}
() {
(!.) {
..();
;
}
message = ;
phoneNumber = appointment...(, );
jid = ;
..(jid, { : message });
}
() {
message = ;
phoneNumber = appointment...(, );
jid = ;
..(jid, { : message });
}
() {
(!message.) ;
text = message.. ||
message..?.;
= message..;
..();
(text?.() === || text?.() === ) {
..(, {
:
});
} (text?.() === ) {
..(, {
:
});
}
crisisKeywords = [, , , ];
(crisisKeywords.( text?.().(keyword))) {
.(, text);
}
}
() {
..();
}
}
3. AFIP Electronic Invoicing
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_CERTIFICATE_PATH,
key: process.env.AFIP_PRIVATE_KEY_PATH,
production: process.env.AFIP_PRODUCTION_MODE === 'true',
});
}
async generateInvoice(data: {
appointmentId: string;
patientCUIT: string;
amount: number;
invoiceType: 'A' | 'B' | 'C';
}) {
lastInvoice = ...(
,
.(data.)
);
invoiceNumber = lastInvoice + ;
invoiceData = {
: ,
: ,
: .(data.),
: ,
: ,
: data..(, ),
: invoiceNumber,
: invoiceNumber,
: .( ()),
: data.,
: ,
: data.,
: ,
: ,
: ,
: ,
: ,
};
response = ...(
invoiceData
);
(response.) {
invoice = ...({
: {
: data.,
: ,
: data.,
: response.,
: .(response.),
: data.,
: ,
},
});
invoice;
} {
();
}
}
(: | | ): {
types = { : , : , : };
types[];
}
(: ): {
date.().()[].(, );
}
(: ): {
year = dateStr.(, );
month = dateStr.(, );
day = dateStr.(, );
();
}
() {
invoices = ...({
: {
: {
practitionerId,
},
: {
: (year, month - , ),
: (year, month, ),
},
},
: {
: {
: {
: ,
},
},
},
});
totalAmount = invoices.( sum + inv., );
{
month,
year,
: invoices.,
totalAmount,
invoices,
};
}
}
4. Claude AI Orchestration
import { Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ClaudeService {
private client: Anthropic;
constructor(private prisma: PrismaService) {
this.client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
}
async summarizeClinicalNote(sessionId: string): Promise<string> {
const session = await this.prisma.session.findUnique({
where: { id: sessionId },
include: {
patient: true,
practitioner: true,
},
});
(!session.) {
();
}
prompt = ;
message = ...({
: process.. || ,
: ,
: [{ : , : prompt }],
});
summary = message.[].;
...({
: { : sessionId },
: { : summary },
});
summary;
}
(: , : ): <> {
prompt = ;
message = ...({
: process.. || ,
: ,
: [{ : , : prompt }],
});
message.[].;
}
(: ): <{
: ;
: ;
: [];
}> {
sessions = ...({
: { patientId },
: { : },
: ,
: {
: ,
},
});
sessionSummaries = sessions
.(
)
.();
prompt = ;
message = ...({
: process.. || ,
: ,
: [{ : , : prompt }],
});
responseText = message.[].;
jsonMatch = responseText.();
(jsonMatch) {
.(jsonMatch[]);
}
();
}
}
5. LiveKit Video Consultations
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 createVideoRoom(appointmentId: string): Promise<{
roomName: string;
practitionerToken: string;
patientToken: string;
}> {
const appointment = await this.prisma.appointment.findUnique({
where: { id: appointmentId },
include: {
patient: true,
practitioner: true,
},
});
if (!appointment) {
throw new Error('Appointment not found');
}
roomName = ;
practitionerToken = .(
roomName,
,
{
: ,
: ,
: ,
: ,
}
);
patientToken = .(
roomName,
,
{
: ,
: ,
: ,
: ,
}
);
...({
: { : appointmentId },
: {
: roomName,
: ,
},
});
{
roomName,
practitionerToken,
patientToken,
};
}
(
: ,
: ,
: {
: ;
: ;
: ;
: ;
}
): {
at = (
process..,
process..,
{
identity,
}
);
at.({
: ,
: roomName,
...permissions,
});
at.();
}
}
Video Room Component (Svelte 5)
<!-- apps/frontend/src/routes/video/[roomName]/+page.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import {
Room,
RoomEvent,
Track,
type RemoteParticipant,
type RemoteTrack,
} from 'livekit-client';
const roomName = $page.params.roomName;
let videoContainer: HTMLDivElement;
let room: Room;
let localVideoTrack = $state<HTMLVideoElement | null>(null);
let remoteVideoTrack = $state<HTMLVideoElement | null>(null);
let connected = $state(false);
let audioEnabled = $state(true);
let videoEnabled = $state(true);
async function connectToRoom(token: string) {
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);
await room.connect(process.env.PUBLIC_LIVEKIT_WS_URL, token);
connected = true;
// Publish local tracks
await room.localParticipant.enableCameraAndMicrophone();
}
function handleTrackSubscribed(
track: RemoteTrack,
publication: any,
participant: RemoteParticipant
) {
if (track.kind === Track.Kind.Video) {
const element = track.attach();
element.style.width = '100%';
element.style.height = '100%';
element.style.objectFit = 'cover';
remoteVideoTrack = element;
}
}
function handleTrackUnsubscribed(track: RemoteTrack) {
track.detach();
}
function handleDisconnect() {
connected = false;
}
async function toggleAudio() {
audioEnabled = !audioEnabled;
await room.localParticipant.setMicrophoneEnabled(audioEnabled);
}
async function toggleVideo() {
videoEnabled = !videoEnabled;
await room.localParticipant.setCameraEnabled(videoEnabled);
}
async function endCall() {
await room.disconnect();
window.close();
}
onMount(async () => {
// Fetch token from API
const response = await fetch(`/api/video/token/${roomName}`);
const { token } = await response.json();
await connectToRoom(token);
});
onDestroy(() => {
if (room) {
room.disconnect();
}
});
</script>
<div class="video-room">
<div class="video-container" bind:this={videoContainer}>
{#if remoteVideoTrack}
{@html remoteVideoTrack.outerHTML}
{:else}
<div class="waiting-message">Esperando al otro participante...</div>
{/if}
</div>
<div class="local-video