Twenty-three tools cover the contact-intelligence CRM — directory, memory, live context, proactive outreach, analytics, and AI-assisted drafting.
All endpoints follow the pattern POST https://mcp.vlozi.app/tools/contacts.<name> with Authorization: Bearer ls_xxx. All responses use this envelope:
{ "data": <payload>, "error": null } // success
{ "data": null, "error": "..." } // failure (with HTTP 4xx/5xx)NOTE
Every tool takes ids as flat fields (contact_id, id) in the query string or JSON body — never as URL path segments, since the MCP gateway can only fill query params or a JSON body.
Directory
contacts.list_contacts
List contacts with their live state (mood, energy, relationship stage, churn risk, message count, last-active). Use before get_contact_profile to find a contact id.
Scope: contacts:contacts.read
Input:
| Field | Type | Notes |
|---|---|---|
channel |
"whatsapp" | "telegram" | "widget" | "instagram" | "all" |
Default all |
stage |
"new" | "building" | "established" | "deep" | "fading" | "dormant" | "all" |
Default all |
sort |
"recent" | "churn_risk" | "messages" | "created" |
Default recent |
q |
string |
Substring match over display name / external id |
limit |
number |
1–100, default 20 |
offset |
number |
Default 0 |
Response: { contacts: ContactRow[], total: number, limit: number, offset: number } — each row includes mood/energy/relationshipStage/totalMessages/churnRisk/lastActiveAt (null if the contact has no state row yet).
NOTE
Soft-deleted contacts are excluded automatically.
curl -X POST https://mcp.vlozi.app/tools/contacts.list_contacts \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"stage": "fading", "sort": "churn_risk", "limit": 10}'contacts.get_contact_profile
Full profile for one contact: the contact row, its state, up to 50 top memories by importance, and resolved entities.
Scope: contacts:contacts.read
Input: { contact_id: string } (required)
Response: { contact, state: object | null, memories: Memory[], entities: Entity[] }
NOTE
Returns 404 if the contact doesn't exist or is soft-deleted.
contacts.get_contact_channels
List the channels a contact has been reached on, with the external id and match confidence per channel. Reflects multi-channel identity after merges.
Scope: contacts:contacts.read
Input: { contact_id: string } (required)
Response: { channels: { id, channelType, externalId, confidence, linkedAt }[] }, newest-linked first
NOTE
No existence check — an unknown contact_id returns channels: [], not a 404.
contacts.get_contact_timeline
Emotional + relationship trajectory over a lookback window: per-message mood/energy points, weekly emotional snapshots, and relationship snapshots (stage, score, trajectory, velocity).
Scope: contacts:contacts.read
Input: { contact_id: string, days?: number } (days 1–365, default 90)
Response: { days: number, mood_points: { mood, energy, role, at }[], emotional_snapshots: { periodType, periodStart, dominantMood, moodScore, variance, trendDirection, keyTriggers }[], relationship_snapshots: { periodStart, stage, relationshipScore, trajectory, velocity }[] }
NOTE
mood_points is capped at 500 rows even within the requested window.
contacts.create_contact
Create a contact for a channel identity, or return the existing one if that (channel, external_id) is already known.
Scope: contacts:contacts.create
Input:
| Field | Type | Notes |
|---|---|---|
external_id |
string (required) |
Channel-native id (phone, telegram id, widget id…), 1–200 chars |
channel |
"whatsapp" | "telegram" | "widget" | "instagram" (required) |
|
display_name |
string |
Optional, ≤200 chars |
Response: { contact: { id, ... }, created: boolean }, HTTP 201 on a true create
NOTE
Idempotent — checked against the channel-identity table first (survives merges), then the legacy (external_id, channel) pair. On the already-exists branch (created: false), contact is only { id } — not the full row. On a true create, contact echoes the full input plus id.
curl -X POST https://mcp.vlozi.app/tools/contacts.create_contact \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"external_id": "+15551234567", "channel": "whatsapp", "display_name": "Ben"}'contacts.merge_contacts
Merge a source contact into a target: re-parents memories, entities, channels, logs, and outreach onto the target (deduping collisions), folds engagement counters, then soft-deletes the source.
Scope: contacts:contacts.delete
Input: { target_contact_id: string, source_contact_id: string } (both required)
Response: { merged: true, target: string, channel_count: number }
CAUTION
Irreversible — no undo tool exists. Real conflict-resolution rules worth knowing: entities unique per (type, name) — a colliding source entity is dropped, not merged; a colliding source channel (channelType, externalId) is also dropped; memories, interaction logs, and outreach triggers always re-parent (no collision possible); the source's emotional/relationship snapshots are deleted outright (they regenerate on the next weekly consolidation); engagement counters (message/session counts, longest streak) are summed/maxed into the target. Returns 400 if target and source are the same id, 404 if either doesn't belong to the tenant.
contacts.delete_contact
GDPR deletion: soft-deletes and anonymises the contact, then hard-deletes its memories, entities, and interaction logs.
Scope: contacts:contacts.delete
Input: { contact_id: string } (required)
Response: { deleted: true }
CAUTION
Irreversible for the memories/entities/logs even though the contact row itself is only a tombstone — displayName is cleared and externalId rewritten to deleted_<id>. Confirm with the user before calling.
contacts.export_contact
GDPR data export: the contact row, its state, all memories and entities, and the interaction count — everything held about the contact.
Scope: contacts:contacts.read
Input: { contact_id: string } (required)
Response: { export_date: string, contact, state: object | null, memories: Memory[], entities: Entity[], interaction_count: number }
NOTE
Unlike get_contact_profile, memory/entity lists here are unbounded (no top-50 cap), and — deliberately, since this is a compliance export — it does not exclude a soft-deleted contact.
Memory
contacts.list_memories
List a contact's memories (facts, preferences, episodes, patterns), highest-importance first, with type, importance, access count, and linked entity ids.
Scope: contacts:memory.read
Input: { contact_id: string } (required)
Response: { memories: { id, memoryType, content, importance, accessCount, entityIds, createdAt, accessedAt }[] } (up to 100)
NOTE
Includes superseded memories (ones a correct_memory call has since replaced) — this list isn't filtered to "currently active" the way correct_memory's own lookup is.
contacts.delete_all_memories
Delete ALL memories for a contact — the contact and its state row are kept.
Scope: contacts:memory.delete
Input: { contact_id: string } (required)
Response: { deleted: true }
CAUTION
Returned unconditionally, even for an unknown contact or one with zero memories. Irreversible.
contacts.correct_memory
Correct a contact's memory. hint fuzzy-matches (case-insensitive substring) the highest-importance active memory; that memory is superseded and a new corrected memory is stored.
Scope: contacts:context.write
Input: { contact_id: string, hint: string, correction: string } (all required — hint 1–500 chars, correction 1–2000 chars)
Response: { corrected: true, memory_id: string } — id of the new corrected memory, not the one it replaced
NOTE
This is a supersede, not an in-place edit — both the old and new memory rows persist in the database; the old one is just marked superseded so it drops out of active-memory views. Returns MEMORY_NOT_FOUND if no active memory matches hint.
Context
contacts.get_context
Build the LLM context packet for a contact — the same hot-path context the chat engine itself uses.
Scope: contacts:context.read
Input:
| Field | Type | Notes |
|---|---|---|
contact_id |
string (required) |
|
message |
string |
Current user message, ≤500 chars — enables semantic + entity-graph recall |
session_count |
number |
Sessions seen so far, drives cold-start vs summary; default 0 |
bot_id |
string |
For a cold-start brief lookup |
Response:
{
"session_core": "string | null",
"message_relevance": "string | null",
"has_message_context": false,
"relationship": { "stage": "...", "trajectory": null, "velocity": null, "mood_trend": null },
"crisis_level": "...",
"triggers": []
}NOTE
If session_count is below the contact's cold-start threshold, session_core comes from a bot-specific cold-start brief instead of the usual rolling summary (falls back to a generic new-contact string if unavailable). message_relevance/has_message_context only populate when message is passed — a semantic + light entity-graph search over memories; a search failure is swallowed, never surfaced as an error. triggers lists any already-due pending outreach (up to 3) the caller should be aware of inline.
Outreach
contacts.list_outreach
List outreach triggers (scheduled proactive messages), newest scheduled first.
Scope: contacts:outreach.read
Input:
| Field | Type | Notes |
|---|---|---|
status |
"pending" | "fired" | "cancelled" | "failed" | "expired" | "all" |
Default all |
contact_id |
string |
Restrict to one contact |
limit |
number |
1–100, default 20 |
offset |
number |
Default 0 |
Response: { triggers: OutreachTrigger[], total: number, limit: number, offset: number }
contacts.schedule_outreach
Schedule a proactive outreach message to a contact at a future time.
Scope: contacts:outreach.create
Input:
| Field | Type | Notes |
|---|---|---|
contact_id |
string (required) |
|
trigger_type |
"scheduled" | "inactivity" | "milestone" | "recurring" (required) |
Why this outreach fires |
scheduled_at |
string (required) |
Strict ISO-8601 UTC, e.g. 2026-07-10T09:00:00Z |
message |
string |
Concrete body, ≤2000 chars — provide this OR message_template |
message_template |
string |
Rendered at send time |
context |
object |
Arbitrary context passed to the sender |
Response: { trigger: { id, contact_id, trigger_type, scheduled_at, message, message_template, context, status: "pending" } }, HTTP 201
WARNING
Unlike blog.publish_post, a past scheduled_at is not rejected — it just makes the trigger immediately eligible for the next scan. Fails with 404 if the contact doesn't exist, or 403 if the contact has disabled outreach.
curl -X POST https://mcp.vlozi.app/tools/contacts.schedule_outreach \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{
"contact_id": "ct_xxx",
"trigger_type": "milestone",
"scheduled_at": "2026-09-01T09:00:00Z",
"message": "You have been with us a full month — how is it going?"
}'contacts.cancel_outreach
Cancel a pending outreach trigger by id.
Scope: contacts:outreach.create
Input: { id: string } (required — outreach trigger id)
Response: { cancelled: true }
NOTE
Only pending triggers can be cancelled — returns 404 ("Trigger not found or not in pending state") for anything already fired, cancelled, failed, expired, or unknown.
contacts.get_outreach_config
Get the workspace outreach config. Returns sensible defaults if none has been saved yet.
Scope: contacts:outreach.read
Input: none
Response: { config: { enabled, inactivityDays, maxInactivityAttempts, minGapDays, maxPerDay, quietHoursStart, quietHoursEnd, timezone } }
NOTE
Unsaved-workspace defaults: enabled: true, inactivityDays: 3, maxInactivityAttempts: 2, minGapDays: 3, maxPerDay: 3, quietHoursStart: 22, quietHoursEnd: 8, timezone: "Asia/Kolkata".
contacts.update_outreach_config
Update (upsert) the workspace outreach config. Pass only the fields to change.
Scope: contacts:outreach.create
Input:
| Field | Type | Notes |
|---|---|---|
enabled |
boolean |
Master on/off for proactive outreach |
inactivity_days |
number |
1–30 |
max_inactivity_attempts |
number |
1–5 |
min_gap_days |
number |
1–14 |
max_per_day |
number |
1–10 |
quiet_hours_start |
number |
0–23 |
quiet_hours_end |
number |
0–23 |
timezone |
string |
IANA, e.g. Asia/Kolkata |
Response: { config: object }
NOTE
The response echoes only the fields you passed (plus tenantId/updatedAt) — it's not a full re-read of the row. Fields you omitted are preserved in the database but won't appear in this response.
contacts.trigger_outreach_scan
Ops action: run the outreach scanner immediately (fires due triggers, creates inactivity/milestone triggers). Normally runs on a 6-hour cron — use this to force a scan.
Scope: contacts:outreach.create
Input: none
Response: { triggered: number, inactivityCreated: number, milestonesCreated: number, errors: string[] }
IMPORTANT
This runs synchronously in-request, not as a background job — expect it to take noticeably longer than a typical read call, since it processes up to 50 due triggers, generating an AI message for any that don't have one preset and delivering each via the chat engine. It also scans for inactive contacts (respecting the configured caps) and milestone contacts (7-day streak, 100 total messages) and creates new triggers for them. Each of the three stages fails independently into errors[] rather than failing the whole call.
Analytics
contacts.analytics_trends
Engagement overview: total contacts, contacts active in the last 7 days, and the distribution of contacts across relationship stages.
Scope: contacts:analytics.read
Input: none
Response: { total_contacts: number, active_last_7_days: number, stage_distribution: Record<string, number> }
WARNING
total_contacts counts every contact row for the tenant with no soft-delete filter — it can run higher than what list_contacts returns, since merged-away/deleted contacts are still counted here.
contacts.analytics_churn
Churn-risk report: a high/medium-risk count summary plus the top at-risk contacts, ordered by risk.
Scope: contacts:analytics.read
Input: { limit?: number } (1–100, default 20)
Response: { summary: { high_risk: number, medium_risk: number }, at_risk_contacts: { contactId, churnRisk, relationshipStage, lastActiveAt, totalMessages, displayName, channel }[] }
NOTE
"At risk" means churnRisk >= 0.3. high_risk counts churnRisk >= 0.7; medium_risk counts 0.3–0.7.
contacts.analytics_moods
Workspace mood + energy distribution — a count of contacts per current mood and per current energy level.
Scope: contacts:analytics.read
Input: none
Response: { moods: Record<string, number>, energy: Record<string, number> }
AI
contacts.draft_outreach_message
AI-write a personalized proactive message for a contact, using their memories, mood, and relationship context. Returns the text — does not send it. Pair with schedule_outreach.
Scope: contacts:outreach.create
Input: { contact_id: string, goal?: string, tone?: string } (goal e.g. "win-back", "check-in"; tone e.g. "warm", "professional")
Response: { message: string, creditsCharged: number } — message capped at 300 chars
IMPORTANT
Charges a flat 5-credit fee up front, refunded automatically if the contact isn't found or generation fails.
contacts.summarize_contact
AI-summarize what you know about a contact — key memories, preferences, mood, and relationship state — into a short 2–4 sentence brief.
Scope: contacts:contacts.read
Input: { contact_id: string } (required)
Response: { summary: string, creditsCharged: number }
IMPORTANT
Charges a flat 5-credit fee, refunded automatically if generation fails.