Skip to main content
Lesson 13 of 14 · Function Calling and Tool Use

Worked Example: Automated CRM

Lesson 13 of 14 Updated November 2, 2026 Developer
On this page
  1. A Conversation-Driven CRM
  2. The Simulated Database
  3. The CRUD Functions
  4. JSON Tool Specifications
  5. Dispatch and the Loop
  6. Four Scenarios and What They Teach
  7. Moving to Production Without Rewriting Everything
  8. Key Takeaways

A Conversation-Driven CRM

Imagine a CRM where sales reps no longer need to navigate complex interfaces. They speak to an assistant that automatically creates, updates, and retrieves contacts. This is exactly what function calling enables you to build. The difference from the payment tracker in the previous lesson is significant: until now, the model only read data. Here, it writes data, which shifts the focus of the exercise toward validation, confirmation, and duplicate management.

You will implement a conversational CRM with CRUD (Create, Read, Update, Delete) operations on a contact database.

The Simulated Database

A Python dictionary serves as the database for this demonstration. Two contacts are sufficient: one client and one prospect. This setup allows us to test a status transition later in the lesson.

import json
import os
from mistralai.client import Mistral

# In-memory contact database (in production: PostgreSQL, MongoDB, etc.)
contacts_db = {
    "CT001": {
        "name": "Marie Dupont",
        "email": "[email protected]",
        "company": "TechCorp",
        "role": "Chief Technology Officer",
        "phone": "+33 6 12 34 56 78",
        "status": "client",
        "notes": "Interested in the enterprise solution"
    },
    "CT002": {
        "name": "Pierre Martin",
        "email": "[email protected]",
        "company": "StartupIO",
        "role": "CEO",
        "phone": "+33 6 98 76 54 32",
        "status": "prospect",
        "notes": "Meeting scheduled for April"
    }
}

next_id = 3  # Counter for new contacts

The CRUD Functions

get_contact and search_contacts handle read operations. The latter accepts a search field argument to avoid creating multiple nearly identical functions. create_contact is the first function that modifies system state, and it illustrates a rule often overlooked in conversational modes: uniqueness checks must live in your code, never in the prompt given to the model. Nothing prevents a sales rep from dictating the same contact twice; the email verification at the start of the function returns duplicate_email with the existing ID, allowing the model to explain the refusal precisely.

update_contact uses an optional fields approach: only provided fields are modified, and the function returns a list of what actually changed. This list is not decorative—it enables the assistant to confirm the modification with an exact sentence rather than a vague “done.”

def get_contact(contact_id: str) -> str:
    """Retrieves information for a contact."""
    if contact_id in contacts_db:
        contact = contacts_db[contact_id].copy()
        contact["contact_id"] = contact_id
        return json.dumps(contact, ensure_ascii=False)
    return json.dumps({"error": f"Contact {contact_id} not found"})


def search_contacts(query: str, field: str = "name") -> str:
    """Searches for contacts by name, company, or status."""
    results = []
    for cid, contact in contacts_db.items():
        if field in contact and query.lower() in contact[field].lower():
            entry = contact.copy()
            entry["contact_id"] = cid
            results.append(entry)

    if results:
        return json.dumps({"results": results, "count": len(results)},
                          ensure_ascii=False)
    return json.dumps({"results": [], "count": 0,
                        "message": f"No contact found for '{query}' in '{field}'"})


def create_contact(name: str, email: str, company: str,
                   role: str = "", phone: str = "",
                   status: str = "prospect", notes: str = "") -> str:
    """Creates a new contact in the CRM."""
    global next_id

    # Check if email already exists
    for cid, contact in contacts_db.items():
        if contact["email"].lower() == email.lower():
            return json.dumps({
                "error": "duplicate_email",
                "message": f"A contact with this email already exists: {cid}"
            })

    contact_id = f"CT{next_id:03d}"
    next_id += 1

    contacts_db[contact_id] = {
        "name": name,
        "email": email,
        "company": company,
        "role": role,
        "phone": phone,
        "status": status,
        "notes": notes
    }

    return json.dumps({
        "success": True,
        "contact_id": contact_id,
        "message": f"Contact {name} created with ID {contact_id}"
    }, ensure_ascii=False)


def update_contact(contact_id: str, **fields) -> str:
    """Updates one or more fields of an existing contact."""
    if contact_id not in contacts_db:
        return json.dumps({"error": f"Contact {contact_id} not found"})

    updated_fields = []
    for key, value in fields.items():
        if key in contacts_db[contact_id]:
            contacts_db[contact_id][key] = value
            updated_fields.append(key)

    if updated_fields:
        return json.dumps({
            "success": True,
            "contact_id": contact_id,
            "updated_fields": updated_fields,
            "message": f"Contact {contact_id} updated: {', '.join(updated_fields)}"
        }, ensure_ascii=False)

    return json.dumps({"error": "No valid fields to update"})

The ensure_ascii=False parameter present in several returns is not a cosmetic detail: without it, “Interested” becomes a string of Unicode escape sequences in the model’s context, which is less readable and more expensive in tokens.

JSON Tool Specifications

Two mechanisms carry most of the reliability here. The enum on status prevents the model from inventing values: it must choose among prospect, client, partner, and inactive, protecting your data integrity without additional validation. The required array does the rest by distinguishing mandatory from optional fields—for create_contact, a contact without a name, email, or company makes no sense, while a phone number can come later.

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_contact",
            "description": "Retrieves all information for a contact by ID",
            "parameters": {
                "type": "object",
                "properties": {
                    "contact_id": {
                        "type": "string",
                        "description": "Contact ID (e.g., CT001)"
                    }
                },
                "required": ["contact_id"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_contacts",
            "description": "Searches for contacts by name, company, or status",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Search term"
                    },
                    "field": {
                        "type": "string",
                        "enum": ["name", "company", "status", "role"],
                        "description": "Field to search in (default: name)"
                    }
                },
                "required": ["query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "create_contact",
            "description": "Creates a new contact in the CRM",
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "Full name of the contact"
                    },
                    "email": {
                        "type": "string",
                        "description": "Email address of the contact"
                    },
                    "company": {
                        "type": "string",
                        "description": "Company name"
                    },
                    "role": {
                        "type": "string",
                        "description": "Job title or function within the company"
                    },
                    "phone": {
                        "type": "string",
                        "description": "Phone number"
                    },
                    "status": {
                        "type": "string",
                        "enum": ["prospect", "client", "partner", "inactive"],
                        "description": "Contact status"
                    },
                    "notes": {
                        "type": "string",
                        "description": "Free-form notes about the contact"
                    }
                },
                "required": ["name", "email", "company"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "update_contact",
            "description": "Updates information for an existing contact",
            "parameters": {
                "type": "object",
                "properties": {
                    "contact_id": {
                        "type": "string",
                        "description": "ID of the contact to modify (e.g., CT001)"
                    },
                    "name": {"type": "string", "description": "New name"},
                    "email": {"type": "string", "description": "New email"},
                    "company": {"type": "string", "description": "New company"},
                    "role": {"type": "string", "description": "New job title"},
                    "phone": {"type": "string", "description": "New phone number"},
                    "status": {
                        "type": "string",
                        "enum": ["prospect", "client", "partner", "inactive"],
                        "description": "New status"
                    },
                    "notes": {"type": "string", "description": "New notes"}
                },
                "required": ["contact_id"]
            }
        }
    }
]

Dispatch and the Loop

Since the functions depend on no external objects, the dispatch dictionary reduces to a direct mapping. The system message, however, carries two behavioral constraints absent from the code: confirm every create or update action with details, and request missing information before creating a contact. Without that second instruction, the model tends to fill required fields with plausible values.

available_functions = {
    "get_contact": get_contact,
    "search_contacts": search_contacts,
    "create_contact": create_contact,
    "update_contact": update_contact,
}

client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
model = "mistral-large-latest"

messages = [
    {
        "role": "system",
        "content": "You are a CRM assistant. You help sales reps "
                   "manage their contacts. Respond in English. "
                   "When you create or modify a contact, confirm "
                   "the action with details. Request missing "
                   "information before creating a contact."
    }
]

Four Scenarios and What They Teach

The first scenario shows the model choosing field="company" on its own even though the user specified nothing: the schema enum provided the options, and the question phrasing did the rest. The second provides information in a jumble, without labels, and the model correctly distributes them among name, email, company, and title. The third is the richest: “Switch Pierre Martin to client” contains no ID, so the model must chain a search then an update, inferring the contextual note along the way. The fourth reminds us that the model can repurpose a function from its literal use to answer a counting question.

# Scenario 1: Search for a contact
chat("Find me contacts at TechCorp")
# -> The model calls search_contacts(query="TechCorp", field="company")
# -> "I found 1 contact at TechCorp: Marie Dupont (CT001),
#     Chief Technology Officer, status client."

# Scenario 2: Create a contact
chat("Add a new contact: Sophie Laurent, [email protected], "
     "DataViz, Data Scientist")
# -> The model calls create_contact(name="Sophie Laurent",
#     email="[email protected]", company="DataViz", role="Data Scientist")
# -> "Contact created successfully! Sophie Laurent (CT003) has been added
#     as a prospect at DataViz."

# Scenario 3: Update a status
chat("Switch Pierre Martin to client, he just signed")
# -> The model first searches for Pierre Martin, then calls
#    update_contact(contact_id="CT002", status="client",
#                   notes="Contract signed in April 2026")
# -> "Pierre Martin (CT002) is now marked as a client.
#     I added a note about the contract signature."

# Scenario 4: Question without a dedicated function
chat("How many prospects do we have?")
# -> The model calls search_contacts(query="prospect", field="status")
# -> "You currently have 1 prospect: Sophie Laurent at DataViz."

Moving to Production Without Rewriting Everything

The in-memory dictionary obviously disappears as soon as there are multiple users. Replacing it with a real database, however, requires touching only one layer: the function queries your CRM’s ORM or API and still returns the same JSON string.

# Functions call your CRM's ORM or API
def get_contact_production(contact_id: str) -> str:
    """Production version with PostgreSQL."""
    contact = db.session.query(Contact).filter_by(id=contact_id).first()
    if contact:
        return json.dumps(contact.to_dict(), ensure_ascii=False)
    return json.dumps({"error": f"Contact {contact_id} not found"})

The rest of the code—tools, dispatch, conversation loop—remains identical. This is the strength of this architecture: only the business logic functions change, and the conversational layer remains unaware of your persistence choices.

Key Takeaways

  • Function calling turns an LLM into a natural interface for a CRM
  • CRUD operations map directly to functions with JSON Schema
  • The model can chain search then update within a single conversation
  • The architecture is decoupled: change the database without touching conversation logic
  • The system message is crucial for guiding the model on confirmations and missing information