Skip to content

AI Origin & Member Attribution

Every outbound message tracks two attribution fields: ai_origin records which AI feature composed it, and sent_by_member_id records which team member triggered the send. Together they power audit trails, analytics, and the dashboard breakdown.

The sent_by_member_id field is a nullable UUID foreign key to member. User-initiated sends automatically populate it from the session; system sends leave it null.

Sourcesent_by_member_idai_origin
User types and sends manuallySession memberNone
AI Assistant compose (ai_send_sms_tool)Session memberAssistant
Bulk SMS tool (ai_send_bulk_sms_tool)Session memberAssistant
User accepts inline suggestionSession memberSuggestion
Text Agent auto-replyNoneTextAgent
Plan send-SMS / send-email stepNonePlan
Twilio inbound / syncNoneNone
Resend inbound webhookNoneNone
ALTER TABLE message
ADD COLUMN sent_by_member_id UUID REFERENCES member(id) ON DELETE SET NULL;
CREATE INDEX idx_message_sent_by_member_id ON message (sent_by_member_id);

ON DELETE SET NULL preserves the message if a member is removed from the organization.

pub enum AiOrigin {
Assistant, // Sent via the in-app AI Assistant
Suggestion, // User accepted an inline AI suggestion
TextAgent, // Auto-replied by a Text Agent
Plan, // Sent by an automated plan step
}

Each variant serializes to snake_case ("assistant", "suggestion", "text_agent", "plan"). The enum implements FromStr and Display for round-trip conversion.

display_label() returns the human-readable name used in UI tooltips and dashboard charts:

Variantas_str()display_label()
Assistant"assistant""Assistant"
Suggestion"suggestion""Suggestion"
TextAgent"text_agent""Text Agent"
Plan"plan""Automated Plan"

For user-initiated sends, the server reads session.member_id and sets sent_by_member_id automatically — the client never sends it. For AI origin, client-driven paths like inline suggestions pass ai_origin: "suggestion" in the request body, while server-driven paths (assistant tools, text agent, plans) set it internally.

For suggestions, the client tracks origin in a signal. When the user clicks Use on a suggestion, the compose bar stores "suggestion" and passes it to POST /api/messages/send.

Pass ai_origin as an optional string in the send-message request:

POST /api/messages/send
{
"contact_id": "a1b2c3d4-...",
"channel": "Sms",
"body": "Thanks for reaching out! I can help with that.",
"phone_number_id": "e5f6a7b8-...",
"ai_origin": "suggestion"
}

The server parses the string to AiOrigin and returns 400 for invalid values. Omit the field or pass null for human-composed messages.

Outbound messages with a non-null ai_origin display a sparkle icon (✨) next to the timestamp. Hovering shows a tooltip with the feature name:

  • Assistant → “Composed by Loquent AI (Assistant)”
  • Suggestion → “Composed from Suggestion”
  • Text Agent → “Auto-replied by Text Agent”
  • Plan → “Sent by Automated Plan”

The indicator renders in MessageEntry after the delivery status icon:

if let Some(ref origin) = message.ai_origin {
Tooltip {
text: ai_origin_tooltip(origin),
side: TooltipSide::TopRight,
span { class: "inline-flex items-center",
Sparkles { class: "w-3 h-3 text-primary/50" }
}
}
}

A new dashboard card shows AI-composed message volume and breakdown by feature.

GET /api/dashboard/ai-message-origin-stats?time_range=Last7Days

Response:

{
"total_ai_messages": 42,
"total_messages": 100,
"by_origin": [
{ "origin": "Assistant", "count": 25 },
{ "origin": "Suggestion", "count": 10 },
{ "origin": "Text Agent", "count": 5 },
{ "origin": "Automated Plan", "count": 2 }
]
}

The query filters outbound messages by organization_id, time range, and dashboard scope (org-wide or scoped to member’s assigned phones). Results are grouped by ai_origin with a B-tree index for fast aggregation.

pub struct AiMessageOriginStats {
pub total_ai_messages: u64,
pub total_messages: u64,
pub by_origin: Vec<AiOriginCount>,
}
pub struct AiOriginCount {
pub origin: String,
pub count: u64,
}

The AiMessageOrigin component renders:

  • Total AI messages count and AI share percentage
  • Horizontal bar chart showing each feature’s contribution, sorted by count descending

Empty states: “No messages yet” when total_messages is zero, “No AI-composed messages yet” when only total_ai_messages is zero.

Before this feature, source mixed transport-level and feature-level values ("text_agent", "plan"). Now:

  • source tracks transport only: "loquent", "external", "import"
  • ai_origin tracks the AI feature: "assistant", "suggestion", "text_agent", "plan"

Text Agent and Plan messages now use source: "loquent" with the appropriate ai_origin value.

Two columns track attribution:

  • message.ai_originVARCHAR, nullable. Index: idx_message_ai_origin (B-tree) for GROUP BY performance. Migration: m20260403_120000_message_add_ai_origin.
  • message.sent_by_member_idUUID, nullable FK to member(id) with ON DELETE SET NULL. Index: idx_message_sent_by_member_id. Migration: m20260405_120000_message_add_sent_by_member_id.

Existing messages have both fields as NULL. Parse errors on ai_origin silently fall back to None.