Skip to content

Architecture Overview

Opsome is a multi-tenant Laravel application. Each tenant is a store. Each store has three independently-configured adapters.

Request flow (inbound message)

Customer message (Chatwoot)

POST /webhook/chatwoot/{tenantId}
        ↓ verify HMAC, filter events, normalize
InboundMessageDTO

ProcessInboundMessageJob
        ↓ resolve tenant → load adapters
        ↓ fetch context (orders, products, conversation history)
        ↓ run LLM agent (confidence ≥ 80%)

ApprovalRequest (status: pending)
        + internal note posted to Chatwoot
        ↓ store owner approves in dashboard
ExecuteApprovalJob

ChatwootChannelAdapter::sendReply()

Tenant adapter configuration

Each tenant stores three adapter configs (encrypted) in the tenants table:

tenants.commerce_adapter  →  { type: 'lunarphp', base_url: '...', token: '...' }
tenants.product_adapter   →  { type: 'google_sheets', sheet_id: '...', service_account: {...} }
tenants.channel_adapter   →  { type: 'chatwoot', base_url: '...', api_key: '...', account_id: '...' }

An AdapterFactory resolves the correct implementation from the type field.

Key design constraints

  • Adapters never create approval requests — only read or execute after approval
  • Identity is always resolved from the channel contact profile — never from message content
  • All writes are approval-gated except assignConversation (internal, reversible)
  • Confidence threshold: ≥80% — below this the agent escalates to human queue

Agent configuration

Each tenant stores an agent_config (encrypted) alongside the adapter configs:

tenants.agent_config  →  {
  provider:       "openrouter",
  model:          "zhipu/glm-4-flash",
  api_key:        "<encrypted>",
  history_window: 10,
  store_name:     "buy2give.store",
  store_tone:     "friendly, professional"
}

The SupportAgentService builds a lean system prompt from store_name + store_tone + output schema rules, then injects a runtime <context> block containing orders, last history_window messages, and any product hits.

The LLM returns a single JSON response:

json
{
  "intent":        "order_status | refund_request | product_qa | other",
  "action_type":   "reply | escalate | refund | cancel | resolve",
  "confidence":    85,
  "draft":         "Hi Sarah, your order shipped yesterday...",
  "internal_note": "Customer asking about delay. Order shows shipped."
}

Hard overrides applied in code: refund and cancel action types always force confidence = 0, ensuring they enter the human approval queue unconditionally.

Directory structure

app/
  Adapters/
    Commerce/    LunarPHPCommerceAdapter, WooCommerceAdapter, ...
    Product/     GoogleSheetsProductAdapter, AkeneoProductAdapter, ...
    Channel/     ChatwootChannelAdapter, FreshchatChannelAdapter, ...
  Contracts/     CommerceAdapterContract, ProductAdapterContract, ChannelAdapterContract
                 LlmClientContract
  DTOs/          OrderDTO, CustomerDTO, ConversationDTO, MessageDTO, ProductDTO,
                 InboundMessageDTO, AgentResponseDTO
  Http/
    Controllers/
      Webhook/   ChatwootWebhookController, ...
  Jobs/          ProcessInboundMessageJob, ExecuteApprovalJob
  Models/        Tenant, ApprovalRequest, User
  Services/      TenantContext, AdapterFactory, SupportAgentService
  Llm/           OpenRouterLlmClient