DEV Community

Oluwadahunsi Ifeoluwa
Oluwadahunsi Ifeoluwa

Posted on • Edited on

Building Eunoia: A Mental Wellbeing Companion

What Does "Eunoia" Even Mean?

Before we dive into the code, let's talk about the name. Eunoia comes from Ancient Greek and means "beautiful thinking" or "well mind." It's one of those perfect words that captures exactly what I’m trying to achieve, a state of mental clarity, goodwill and inner peace.

Technical Architecture

Eunoia is an AI-driven mental wellbeing agent built on top of the A2A (Agent-to-Agent) protocol. At its core, Eunoia handles emotional check-ins, reflection journaling, and empathetic conversation generation using Google’s Gemini API.

A2A Message Handling

The A2A layer acts as the gateway for inter-agent communication built on JSON-RPC 2.0, enabling AI agents to exchange messages in a structured and consistent way. It defines the core rules for:

Message format and structure: how requests and responses are represented.

Task lifecycle management: tracking how an intent or request is initiated, processed, and completed.

Artifact handling and history tracking : managing generated outputs and maintaining conversation context.

Error handling and status reporting: ensuring predictable, debuggable responses across agents.

Project Setup

Eunoia is written in Go and built to support autonomous agent communication through the A2A protocol. The project is structured to keep each component modular — agents, repositories, and handlers are defined separately but work together through standard interfaces.

Initialize the Project

mkdir eunoia && cd eunoia go mod init github.com/yourusername/eunoia

Enter fullscreen mode Exit fullscreen mode

Create the folder structure

mkdir -p cmd/app internal/{a2a,agent,checkin,reflection,conversation,routes} pkg/{config,database} migrations

Enter fullscreen mode Exit fullscreen mode

Install Dependencies

go get github.com/go-sql-driver/mysql
go get github.com/gorilla/mux
go get github.com/joho/godotenv
go get google.golang.org/api/option
go get github.com/google/generative-ai-go/genai
Enter fullscreen mode Exit fullscreen mode

Configuration

Eunoia uses environment variables for runtime configuration.

# Server Configuration
PORT=8080
APP_ENV=development

# Database Configuration
DB_USER=your_db_user
DB_PASS=your_db_password
DB_HOST=localhost
DB_PORT=3306
DB_NAME=eunoia_db

# GEMINI KEY
GEMINI_API_KEY=your_gemini_api_key_here
Enter fullscreen mode Exit fullscreen mode

In pkg/config/config.go:

package config

import (
    "fmt"
    "os"
)

type DBConfig struct {
    User     string
    Password string
    Host     string
    Port     string
    Name     string
}

type AIConfig struct {
    GeminiAPIKey string
}

type Config struct {
    AppEnv string
    Port   string
    DB     DBConfig
    AI     AIConfig
}

func LoadConfig() *Config {
    config := &Config{
        Port: getEnv("PORT"),
        DB: DBConfig{
            User:     getEnv("DB_USER"),
            Password: getEnv("DB_PASS"),
            Host:     getEnv("DB_HOST"),
            Port:     getEnv("DB_PORT"),
            Name:     getEnv("DB_NAME"),
        },

        AppEnv: getEnv("APP_ENV"),
        AI: AIConfig{
            GeminiAPIKey: getEnv("GEMINI_API_KEY"),
        },
    }

    return config
}

func getEnv(key string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }

    panic(fmt.Sprintf("%s is required", key))
}

Enter fullscreen mode Exit fullscreen mode

Entry Point

The main entrypoint is cmd/app/main.go It loads environment variables, connects to the database, and starts the HTTP server.

package main

import (
    "fmt"
    "net/http"

    "github.com/zjoart/eunoia/cmd/routes"
    "github.com/zjoart/eunoia/internal/config"
    "github.com/zjoart/eunoia/internal/database"
    "github.com/zjoart/eunoia/pkg/logger"

    "github.com/joho/godotenv"
)

func main() {

    if err := godotenv.Load(); err != nil {
        logger.Warn("No .env file found", logger.WithError(err))
    }

    cfg := config.LoadConfig()

    db, errDb := database.InitDB(&cfg.DB)
    if errDb != nil {
        logger.Fatal("Failed to initialize database", logger.WithError(errDb))
    }

    defer db.Close()

    router := routes.SetUpRoutes(db, cfg)

    addr := fmt.Sprintf(":%s", cfg.Port)
    logger.Info("Service starting", logger.Fields{
        "port": cfg.Port,
    })

    // RUN
    if err := http.ListenAndServe(addr, router); err != nil {
        logger.Fatal("Server failed", logger.WithError(err))
    }
}
Enter fullscreen mode Exit fullscreen mode

A2A Message Types

The A2A layer defines a few core types inside internal/a2a/types.go

package a2a

// A2A message request
type A2ARequest struct {
    JSONRPC string    `json:"jsonrpc"`
    ID      string    `json:"id"`
    Method  string    `json:"method"`
    Params  A2AParams `json:"params"`
}

// A2A request parameters
type A2AParams struct {
    Message       A2AMessage `json:"message"`
    Configuration A2AConfig  `json:"configuration"`
}

// A2A protocol message
type A2AMessage struct {
    Kind      string                 `json:"kind"`
    Role      string                 `json:"role"`
    Parts     []A2APart              `json:"parts"`
    Metadata  map[string]interface{} `json:"metadata"`
    MessageID string                 `json:"messageId"`
}

// A2A message part
type A2APart struct {
    Kind string    `json:"kind"`
    Text string    `json:"text,omitempty"`
    Data []A2APart `json:"data,omitempty"`
}

// A2A requests configuration
type A2AConfig struct {
    AcceptedOutputModes    []string            `json:"acceptedOutputModes"`
    HistoryLength          int                 `json:"historyLength"`
    PushNotificationConfig A2APushNotification `json:"pushNotificationConfig"`
    Blocking               bool                `json:"blocking"`
}

// push notification configuration
type A2APushNotification struct {
    URL            string                 `json:"url"`
    Token          string                 `json:"token"`
    Authentication map[string]interface{} `json:"authentication"`
}

// A2A response
type A2AResponse struct {
    JSONRPC string    `json:"jsonrpc"`
    ID      string    `json:"id"`
    Result  A2AResult `json:"result,omitempty"`
    Error   *A2AError `json:"error,omitempty"`
}

// task result
type A2AResult struct {
    ID        string             `json:"id"`
    ContextID string             `json:"contextId,omitempty"`
    Status    A2ATaskStatus      `json:"status"`
    Artifacts []A2AArtifact      `json:"artifacts,omitempty"`
    History   []A2AMessageResult `json:"history,omitempty"`
    Kind      string             `json:"kind"`
}

// status of a task with message
type A2ATaskStatus struct {
    State     string           `json:"state"`
    Timestamp string           `json:"timestamp"`
    Message   A2AMessageResult `json:"message"`
}

// message in A2A responses
type A2AMessageResult struct {
    MessageID string                 `json:"messageId"`
    Role      string                 `json:"role"`
    Parts     []A2APart              `json:"parts"`
    Kind      string                 `json:"kind"`
    TaskID    string                 `json:"taskId"`
    Metadata  map[string]interface{} `json:"metadata,omitempty"`
}

// artifact in A2A responses
type A2AArtifact struct {
    ArtifactID string    `json:"artifactId"`
    Name       string    `json:"name"`
    Parts      []A2APart `json:"parts"`
}

// error in A2A responses
type A2AError struct {
    Code    int         `json:"code"`
    Message string      `json:"message"`
    Data    interface{} `json:"data,omitempty"`
}

// internal chat response
type ChatResponse struct {
    Response string `json:"response"`
}

Enter fullscreen mode Exit fullscreen mode

Error codes

package a2a

// JSON-RPC 2.0 Error Codes
const (
    ParseError = -32700

    InvalidRequest = -32600

    MethodNotFound = -32601

    InvalidParams = -32602

    InternalError = -32603
)

Enter fullscreen mode Exit fullscreen mode

Business Logic: Services

The conversation service in internal/conversation/service.go orchestrates the intent detection and response generation:

func (s *Service) ProcessMessage(req *ChatRequest) (*ChatResponse, error) {
    if strings.TrimSpace(req.Message) == "" {
        return nil, fmt.Errorf("message cannot be empty")
    }

    userRecord, err := s.userRepo.GetOrCreateUser(req.PlatformUserID)
    if err != nil {
        logger.Error("failed to get or create user", logger.WithError(err))
        return nil, fmt.Errorf("failed to process user: %w", err)
    }

    userMessage := &ConversationMessage{
        ID:             id.Generate(),
        UserID:         userRecord.ID,
        MessageRole:    "user",
        MessageContent: req.Message,
        MessageID:      req.MessageID,
        CreatedAt:      time.Now(),
    }

    if err := s.repo.SaveMessage(userMessage); err != nil {
        logger.Warn("failed to save user message", logger.WithError(err))
    }

    s.detectAndHandleIntents(req.PlatformUserID, req.Message, userRecord.ID)

    context, err := s.buildUserContext(userRecord.ID)
    if err != nil {
        logger.Warn("failed to build user context", logger.WithError(err))
        context = ""
    }

    conversationHistory, err := s.repo.GetRecentMessages(userRecord.ID, 30)
    if err != nil {
        logger.Warn("failed to get conversation history", logger.WithError(err))
        conversationHistory = []*ConversationMessage{}
    }

    geminiHistory := s.convertToGeminiHistory(conversationHistory)

    systemPrompt := s.buildSystemPrompt(context)

    response, err := s.geminiService.GenerateContent(systemPrompt, req.Message, geminiHistory)
    if err != nil {
        logger.Error("failed to generate response", logger.WithError(err))
        return nil, fmt.Errorf("failed to generate response: %w", err)
    }

    assistantMessage := &ConversationMessage{
        ID:             id.Generate(),
        UserID:         userRecord.ID,
        MessageRole:    "assistant",
        MessageContent: response,
        MessageID:      req.MessageID,
        ContextData:    context,
        CreatedAt:      time.Now(),
    }

    if err := s.repo.SaveMessage(assistantMessage); err != nil {
        logger.Warn("failed to save assistant message", logger.WithError(err))
    }

    return &ChatResponse{
        Response: response,
    }, nil
}

Enter fullscreen mode Exit fullscreen mode

Intent Detection Logic

func (s *Service) detectAndHandleIntents(platformUserID, message, userID string) {
    messageLower := strings.ToLower(message)
    messageLen := len(strings.Fields(message))

    moodScore, moodLabel := s.detectMoodIntent(messageLower)
    if moodScore > 0 {
        checkInReq := &checkin.CreateCheckInRequest{
            PlatformUserID: platformUserID,
            MoodScore:      moodScore,
            MoodLabel:      moodLabel,
            Description:    message,
        }
        if _, err := s.checkInService.CreateCheckIn(checkInReq); err != nil {
            logger.Warn("failed to auto-create check-in", logger.WithError(err))
        } else {
            logger.Info("auto-created check-in from conversation", logger.Fields{"mood": moodLabel})
        }
    }

    if messageLen >= 15 && s.isReflectionIntent(messageLower) {
        reflectionReq := &reflection.CreateReflectionRequest{
            PlatformUserID: platformUserID,
            Content:        message,
        }
        if _, err := s.reflectionService.CreateReflection(reflectionReq); err != nil {
            logger.Warn("failed to auto-create reflection", logger.WithError(err))
        } else {
            logger.Info("auto-created reflection from conversation")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The checkin repository in internal/checkin/repository.go

func (r *Repository) CreateCheckIn(checkIn *EmotionalCheckIn) error {
    query := `INSERT INTO emotional_checkins (id, user_id, mood_score, mood_label, description, check_in_date, created_at)
              VALUES (?, ?, ?, ?, ?, ?, ?)`

    _, err := r.db.Exec(query, checkIn.ID, checkIn.UserID, checkIn.MoodScore, checkIn.MoodLabel,
        checkIn.Description, checkIn.CheckInDate, checkIn.CreatedAt)

    if err != nil {
        return err
    }

    return nil
}

func (r *Repository) GetCheckInsByUserID(userID string, limit int) ([]*EmotionalCheckIn, error) {
    query := `SELECT id, user_id, mood_score, mood_label, description, check_in_date, created_at
              FROM emotional_checkins
              WHERE user_id = ?
              ORDER BY check_in_date DESC, created_at DESC
              LIMIT ?`

    rows, err := r.db.Query(query, userID, limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var checkIns []*EmotionalCheckIn
    for rows.Next() {
        checkIn := &EmotionalCheckIn{}
        err := rows.Scan(&checkIn.ID, &checkIn.UserID, &checkIn.MoodScore, &checkIn.MoodLabel,
            &checkIn.Description, &checkIn.CheckInDate, &checkIn.CreatedAt)
        if err != nil {
            return nil, err
        }
        checkIns = append(checkIns, checkIn)
    }

    return checkIns, nil
}
Enter fullscreen mode Exit fullscreen mode

AI Integration

The Gemini service in internal/agent/gemini_service.go handles AI-powered responses

func (g *GeminiService) GenerateContent(systemPrompt string, userMessage string, conversationHistory []string) (string, error) {
    ctx := context.Background()

    // Build full prompt with history embedded in text
    var promptBuilder strings.Builder
    promptBuilder.WriteString(systemPrompt)
    promptBuilder.WriteString("\n\n")

    if len(conversationHistory) > 0 {
        promptBuilder.WriteString("Previous conversation:\n")
        for i, msg := range conversationHistory {
            role := "User"
            if i%2 == 1 {
                role = "Assistant"
            }
            promptBuilder.WriteString(fmt.Sprintf("%s: %s\n", role, msg))
        }
        promptBuilder.WriteString("\n")
    }

    promptBuilder.WriteString("Current message:\n")
    promptBuilder.WriteString(userMessage)

    resp, err := g.model.GenerateContent(ctx, genai.Text(promptBuilder.String()))
    if err != nil {
        logger.Error("failed to generate content", logger.WithError(err))
        return "", fmt.Errorf("failed to generate content: %w", err)
    }

    if len(resp.Candidates) == 0 {
        logger.Error("no candidates in gemini response", logger.Fields{
            "prompt_feedback": resp.PromptFeedback,
        })
        return "", fmt.Errorf("no candidates in response - content may have been blocked")
    }

    if len(resp.Candidates[0].Content.Parts) == 0 {
        logger.Error("no content parts in gemini response", logger.Fields{
            "finish_reason": resp.Candidates[0].FinishReason,
        })
        return "", fmt.Errorf("no content in response")
    }

    var responseText strings.Builder
    for _, part := range resp.Candidates[0].Content.Parts {
        responseText.WriteString(fmt.Sprintf("%v", part))
    }

    return responseText.String(), nil
}
Enter fullscreen mode Exit fullscreen mode

Database Schema

The migrations create the necessary tables

CREATE TABLE IF NOT EXISTS users (
    id VARCHAR(36) PRIMARY KEY,
    telex_user_id VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_telex_user_id (telex_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Emotional check-ins table
CREATE TABLE IF NOT EXISTS emotional_checkins (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL,
    mood_score INT NOT NULL CHECK (mood_score BETWEEN 1 AND 10),
    mood_label VARCHAR(50),
    description TEXT,
    check_in_date DATE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_checkin (user_id, check_in_date),
    INDEX idx_check_in_date (check_in_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Enter fullscreen mode Exit fullscreen mode
-- Reflections table for user journal entries
CREATE TABLE IF NOT EXISTS reflections (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL,
    content TEXT NOT NULL,
    sentiment VARCHAR(20),
    key_themes TEXT,
    ai_analysis TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_reflections (user_id, created_at),
    INDEX idx_sentiment (sentiment)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Conversation history for context-aware responses
CREATE TABLE IF NOT EXISTS conversation_history (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL,
    message_role VARCHAR(20) NOT NULL,
    message_content TEXT NOT NULL,
    context_data TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_conversation (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Enter fullscreen mode Exit fullscreen mode

Running the Application

go run cmd/app/main.go

Testing the A2A Endpoints

To test the A2A endpoints, you can use curl or any HTTP client:

curl -X POST http://localhost:8080/a2a/agent/eunoia \
  -H "Content-Type: application/json" \
  -d '{
  "jsonrpc": "2.0",
  "id": "req-12345",
  "method": "message/send",
  "params": {
    "message": {
      "kind": "message",
      "role": "user",
      "parts": [
        {
          "kind": "text",
          "text": "I'm feeling anxious today"
        }
      ],
      "metadata": {
        "telex_user_id": "user-123",
        "telex_channel_id": "channel-456"
      },
      "messageId": "msg-789"
    },
    "configuration": {
      "acceptedOutputModes": ["text/plain"],
      "historyLength": 0,
      "blocking": false
    }
  }
}'

Expected Response:
{
    "jsonrpc": "2.0",
    "id": "req-12345",
    "result": {
        "id": "bf799994-ad7f-44d7-8e6d-d7000a503a91",
        "contextId": "f4360917-9fc6-469f-b38c-385d9e4501ba",
        "status": {
            "state": "completed",
            "timestamp": "2025-11-04T11:01:14Z",
            "message": {
                "messageId": "msg-789",
                "role": "agent",
                "parts": [
                    {
                        "kind": "text",
                        "text": "Oh, my dear, I hear you again. It sounds like that anxiety is really persistent today, still right there with you. It's completely understandable to feel that heaviness.\n\nSometimes just naming it, even repeatedly, is what we need. Is there anything at all that might bring even a tiny bit of comfort or ease right now, or is it more about just being with the feeling as it is? No pressure, just wondering."
                    }
                ],
                "kind": "message",
                "taskId": "bf799994-ad7f-44d7-8e6d-d7000a503a91",
                "metadata": {
                    "agent": "eunoia"
                }
            }
        },
        "artifacts": [
            {
                "artifactId": "cd1246ad-be79-4c06-bf69-870e05c26fc8",
                "name": "eunoia_response",
                "parts": [
                    {
                        "kind": "text",
                        "text": "Oh, my dear, I hear you again. It sounds like that anxiety is really persistent today, still right there with you. It's completely understandable to feel that heaviness.\n\nSometimes just naming it, even repeatedly, is what we need. Is there anything at all that might bring even a tiny bit of comfort or ease right now, or is it more about just being with the feeling as it is? No pressure, just wondering."
                    }
                ]
            }
        ],
        "history": [
            {
                "messageId": "msg-789",
                "role": "agent",
                "parts": [
                    {
                        "kind": "text",
                        "text": "Oh, my dear, I hear you again. It sounds like that anxiety is really persistent today, still right there with you. It's completely understandable to feel that heaviness.\n\nSometimes just naming it, even repeatedly, is what we need. Is there anything at all that might bring even a tiny bit of comfort or ease right now, or is it more about just being with the feeling as it is? No pressure, just wondering."
                    }
                ],
                "kind": "message",
                "taskId": "bf799994-ad7f-44d7-8e6d-d7000a503a91",
                "metadata": {
                    "agent": "eunoia"
                }
            }
        ],
        "kind": "task"
    }
}
Enter fullscreen mode Exit fullscreen mode

Health Check Endpoint

Test the health endpoint:

curl http://localhost:8080/agent/health

Expected Response:
{
  "status": "healthy",
  "agent": "eunoia",
  "service": "mental wellbeing assistant"
}

Enter fullscreen mode Exit fullscreen mode

Key Features Implemented

Intent Detection: Automatic mood check-in creation and reflection analysis

A2A Protocol Compliance: Full JSON-RPC 2.0 implementation

Platform Agnostic

AI-Powered Responses: Empathetic conversation using Google Gemini

That’s a wrap on Eunoia — an AI agent designed to make mental wellbeing more approachable through structured reflection and emotional awareness. You can explore the full project, contribute ideas, or even fork it to build your own version here: github.com/zjoart/eunoia

Top comments (3)

Collapse
 
oluwajuwonlo_wojuade_3a70 profile image
Oluwajuwonlo Wojuade

Great, kudos dev

Collapse
 
clara_smith_706c4cd2ba405 profile image
Tomiwa Solomon

Nice💯💯

Collapse
 
timilehin_oyerinde_46bc9e profile image
Timilehin Oyerinde

Well done 👍