docs/Appointment Reminder

Appointment Reminder

This example shows how to use Guava to call patients and remind them of upcoming appointments. If a patient needs to reschedule, the agent checks your Amazon Connect queue in real time — and either transfers them directly to a live scheduling agent (if one is free) or captures their callback preference and creates a Connect task (if the queue is busy).

What Happens on the Call

Prerequisites

  • Python 3.10 or later
  • A Guava account with an API key and a phone number — sign up at app.goguava.ai
  • An AWS account with an Amazon Connect instance

Step 1: Install Guava

Choose whichever package manager you prefer:

pip install guava-sdk    # Install using pip
uv add guava-sdk         # Install using uv
poetry add guava-sdk     # Install using poetry

You also need boto3 for the Amazon Connect API:

pip install boto3

Step 2: Set Up Amazon Connect

If you already have a Connect instance configured, skip to the parts you haven't done yet.

2a. Create a Connect Instance

  1. Open the Amazon Connect console.
  2. Click Create instance and follow the setup wizard.
  3. Once the instance is created, open it and copy the Instance ID from the ARN shown in the overview — it's the UUID at the end:
    arn:aws:connect:us-east-1:123456789012:instance/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
                                                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                                     This is your CONNECT_INSTANCE_ID

2b. Create a Contact Flow for Task Routing

Amazon Connect tasks need a dedicated contact flow — the default queue flow does not support tasks.

  1. In your Connect instance, go to Routing → Contact flows → Create contact flow.
  2. Name it something like Guava Task Routing Flow.
  3. Add a Set working queue block and connect it to your scheduling queue.
  4. Add a Transfer to queue block after that.
  5. Add a Disconnect / hang up block at the end of the error branch.
  6. Save and publish the flow.
  7. Open the flow, click Show additional flow information, and copy the Contact flow ID (the UUID at the end of the ARN).

Tip: For a minimal test flow, you can also use any existing inbound contact flow — tasks will be routed to whatever queue the flow sets.

2c. Set Up a Scheduling Queue

  1. Go to Routing → Queues → Add new queue.
  2. Name it (e.g., Scheduling) and assign it to the hours of operation and outbound caller ID of your choice.
  3. Copy the Queue ID from the queue's ARN (the UUID at the end).

2d. Get a Transfer Number

This is the phone number Guava will transfer rescheduling patients to — typically your scheduling desk or the Amazon Connect direct dial number for your scheduling queue.

  • In Connect, go to Channels → Phone numbers to see claimed numbers.
  • Or use any external scheduling desk number in E.164 format (e.g., +15551234567).

Step 3: Configure AWS Credentials

The example uses boto3, which reads credentials from the standard AWS credential chain. The easiest way for local development:

export AWS_ACCESS_KEY_ID="your-access-key-id"
export AWS_SECRET_ACCESS_KEY="your-secret-access-key"
export AWS_DEFAULT_REGION="us-east-1"   # Match your Connect instance region

The IAM user or role you use needs the following permissions:

  • connect:GetCurrentMetricData — to check queue availability
  • connect:StartTaskContact — to create callback tasks

A minimal IAM policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "connect:GetCurrentMetricData",
        "connect:StartTaskContact"
      ],
      "Resource": "arn:aws:connect:*:*:instance/YOUR_INSTANCE_ID/*"
    }
  ]
}

Step 4: Set Environment Variables

# Guava
export GUAVA_API_KEY="your-guava-api-key"
export GUAVA_AGENT_NUMBER="+15551000000"    # Your Guava phone number

# Amazon Connect
export CONNECT_INSTANCE_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export CONNECT_CONTACT_FLOW_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export CONNECT_QUEUE_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export CONNECT_TRANSFER_NUMBER="+15551234567"   # Scheduling desk number

Step 5: Run the Example

python -m examples.integrations.ccaas.amazon_connect.appointment_reminder \
  +15559876543 \
  --name "Jane Doe" \
  --appointment "Thursday, April 17 at 10:30 AM"

Replace +15559876543 with the phone number to call and adjust --name and --appointment as needed.

What to expect:

  • Guava calls the patient and verifies they're the right person
  • If they confirm: you'll see a confirmation logged and an SMS is sent
  • If they need to reschedule: the example checks your Connect queue in real time
    • If agents are free: Guava transfers the patient to CONNECT_TRANSFER_NUMBER
    • If the queue is busy: Guava collects a callback preference and creates a task in your Connect instance — visible to agents in the Contact Control Panel (CCP)
  • If no one picks up: Guava leaves a voicemail

How It Works

1. Defining the Agent and Kicking Off the Call

agent = Agent(
    name="Alex",
    organization="Bright Valley Medical Center",
    purpose="to remind patients of their upcoming appointments and assist with rescheduling when needed",
)

agent.outbound_phone(
    from_number=os.environ["GUAVA_AGENT_NUMBER"],
    to_number=args.phone,
    variables={"patient_name": args.name, "appointment": args.appointment},
).run()

Agent is the top-level handle for the AI persona. outbound_phone places the call and binds the agent to that single conversation. The variables dict is passed into the call and made available throughout — handlers can read each value with call.get_variable(key).

2. Reaching the Right Person (reach_person)

@agent.on_call_start
def on_call_start(call: guava.Call) -> None:
    call.reach_person(contact_full_name=call.get_variable("patient_name"))

reach_person handles the gatekeeper problem — it has the agent confirm they're speaking with the intended patient before proceeding. If a family member answers, the agent politely asks for the patient by name. The reach result is delivered to the handler registered with @agent.on_reach_person.

@agent.on_reach_person
def on_reach_person(call: guava.Call, outcome: str) -> None:
    if outcome == "unavailable":
        call.hangup("Leave a brief voicemail ...")
        return
    # otherwise, set up the reminder task

3. Collecting the Appointment Response

call.set_task(
    "remind",
    objective=...,
    checklist=[
        guava.Say(f"Hi {patient_name}, this is Alex calling from ..."),
        guava.Field(
            key="response",
            description="Ask if they can make the appointment or need to reschedule.",
            field_type="text",
            required=True,
        ),
    ],
)

set_task gives the agent a named objective and a checklist of items to work through. guava.Field tells the agent what information to collect — the patient might say "Yes, I'll be there" or "Actually, I have a conflict that day" — and the agent normalizes that into a structured value you can inspect via call.get_field(...).

4. Classifying the Patient's Response

from guava.helpers.openai import IntentRecognizer

intent_recognizer = IntentRecognizer(["confirmed", "needs to reschedule"])

@agent.on_task_complete("remind")
def handle_response(call: guava.Call) -> None:
    response = call.get_field("response", "")
    if intent_recognizer.classify(response) == "needs to reschedule":
        handle_reschedule(call)
    else:
        ...

When the remind task completes, the registered @agent.on_task_complete("remind") handler runs. Rather than matching against a list of keywords, IntentRecognizer uses an LLM to classify what the patient actually meant — natural phrasing like "I actually have a conflict that day" or "Can we move it?" still maps to needs to reschedule without enumerating every variant. The recognizer is instantiated once at module level and reused across calls.

5. Checking the Connect Queue in Real Time

def check_queue_availability() -> dict:
    response = connect_client.get_current_metric_data(
        InstanceId=os.environ["CONNECT_INSTANCE_ID"],
        Filters={"Queues": [os.environ["CONNECT_QUEUE_ID"]], "Channels": ["VOICE"]},
        CurrentMetrics=[
            {"Name": "AGENTS_AVAILABLE", "Unit": "COUNT"},
            {"Name": "CONTACTS_IN_QUEUE", "Unit": "COUNT"},
        ],
    )
    ...

GetCurrentMetricData returns a real-time snapshot of the queue — how many agents are available and how many contacts are already waiting. This snapshot updates every ~15 seconds.

6. Routing Decision: Transfer vs. Task

queue = check_queue_availability()
if queue["agents_available"] > 0:
    call.transfer(os.environ["CONNECT_TRANSFER_NUMBER"], "...")
else:
    # collect callback preference, then create a task
    call.set_task("callback", ...)

If a scheduler is free, Guava transfers the call live. If not, the agent collects the patient's preferred callback time in a follow-up callback task; its completion handler creates a Connect task via StartTaskContact — complete with the patient's name, original appointment, and preferred callback window. The task appears in your agents' Contact Control Panel (CCP) just like any other contact.

7. SMS Confirmation

call_state = {"appointment_confirmed": False}

# ... inside the "remind" handler, when the patient confirms:
call_state["appointment_confirmed"] = True

# ... after agent.run() returns:
if call_state["appointment_confirmed"]:
    guava.Client().send_sms(...)

After agent.run() returns, if the patient confirmed, we send an SMS reminder via the Guava client. This runs after the call ends, so it never blocks the voice interaction.


Customization Ideas

Pull patient data from a database or CRM Replace the hardcoded --name and --appointment CLI args with a database query or CRM API call, and call patients in a loop:

for patient in get_todays_appointments():
    agent.outbound_phone(
        from_number=os.environ["GUAVA_AGENT_NUMBER"],
        to_number=patient["phone"],
        variables={"patient_name": patient["name"], "appointment": patient["appointment"]},
    ).run()

Set a queue depth threshold Instead of transferring whenever any agent is free, add a threshold — e.g., only transfer if fewer than 3 contacts are already waiting:

if queue["agents_available"] > 0 and queue["contacts_in_queue"] < 3:
    call.transfer(...)

Route by appointment type Different appointment types may need different queues or transfer numbers. Pass the appointment type into the call as a variable and route accordingly:

transfer_number = SPECIALIST_LINE if call.get_variable("type") == "specialist" else GENERAL_LINE
call.transfer(transfer_number, "...")

Add multi-language support Guava supports English, Spanish, French, German, and Italian. The agent's voice can be set per call using the persona configuration on the underlying Call object.

Complete Example

import guava
import os
import logging
import json
import argparse
import boto3
from datetime import datetime

from guava import Agent, logging_utils
from guava.helpers.openai import IntentRecognizer

logger = logging.getLogger("appointment_reminder")

connect_client = boto3.client("connect")

intent_recognizer = IntentRecognizer(["confirmed", "needs to reschedule"])

# State the SMS step needs after the call ends.
call_state: dict = {"appointment_confirmed": False}


agent = Agent(
    name="Alex",
    organization="Bright Valley Medical Center",
    purpose=(
        "to remind patients of their upcoming appointments and assist "
        "with rescheduling when needed"
    ),
)


def check_queue_availability() -> dict:
    """Query Amazon Connect for real-time agent availability in the scheduling queue."""
    try:
        response = connect_client.get_current_metric_data(
            InstanceId=os.environ["CONNECT_INSTANCE_ID"],
            Filters={
                "Queues": [os.environ["CONNECT_QUEUE_ID"]],
                "Channels": ["VOICE"],
            },
            CurrentMetrics=[
                {"Name": "AGENTS_AVAILABLE", "Unit": "COUNT"},
                {"Name": "CONTACTS_IN_QUEUE", "Unit": "COUNT"},
            ],
        )
        agents_available = 0
        contacts_in_queue = 0
        for result in response.get("MetricResults", []):
            for collection in result.get("Collections", []):
                name = collection["Metric"]["Name"]
                value = collection.get("Value") or 0
                if name == "AGENTS_AVAILABLE":
                    agents_available = int(value)
                elif name == "CONTACTS_IN_QUEUE":
                    contacts_in_queue = int(value)
        logger.info(
            "Queue check — agents available: %d, contacts in queue: %d",
            agents_available,
            contacts_in_queue,
        )
        return {"agents_available": agents_available, "contacts_in_queue": contacts_in_queue}
    except Exception as e:
        logger.error("Failed to fetch queue metrics: %s", e)
        return {"agents_available": 0, "contacts_in_queue": 0}


@agent.on_call_start
def on_call_start(call: guava.Call) -> None:
    call.reach_person(contact_full_name=call.get_variable("patient_name"))


@agent.on_reach_person
def on_reach_person(call: guava.Call, outcome: str) -> None:
    if outcome == "unavailable":
        appointment = call.get_variable("appointment")
        call.hangup(
            "We were unable to reach the patient. Leave a brief, friendly voicemail from "
            "Bright Valley Medical Center reminding them of their appointment on "
            f"{appointment} and asking them to call back to confirm or reschedule."
        )
        return

    patient_name = call.get_variable("patient_name")
    appointment = call.get_variable("appointment")
    call.set_task(
        "remind",
        objective=(
            f"Remind {patient_name} of their appointment at Bright Valley Medical "
            f"Center on {appointment}. Ask if they can attend or need to reschedule."
        ),
        checklist=[
            guava.Say(
                f"Hi {patient_name}, this is Alex calling from Bright Valley "
                f"Medical Center with a reminder about your appointment on {appointment}."
            ),
            guava.Field(
                key="response",
                description=(
                    "Ask if they can make the appointment or need to reschedule. "
                    "Capture their answer as 'confirmed' or 'reschedule'."
                ),
                field_type="text",
                required=True,
            ),
        ],
    )


@agent.on_task_complete("remind")
def handle_response(call: guava.Call) -> None:
    response = call.get_field("response", "")
    if intent_recognizer.classify(response) == "needs to reschedule":
        handle_reschedule(call)
    else:
        call_state["appointment_confirmed"] = True
        call.hangup(
            "Thank the patient for confirming. Remind them to arrive 15 minutes "
            "early and bring their insurance card and a photo ID. Wish them well."
        )


def handle_reschedule(call: guava.Call) -> None:
    queue = check_queue_availability()
    if queue["agents_available"] > 0:
        # Scheduling agents are free — transfer directly.
        call.transfer(
            os.environ["CONNECT_TRANSFER_NUMBER"],
            "Let the patient know you're connecting them with a scheduling specialist "
            "who will help find a new appointment time. Then transfer the call.",
        )
    else:
        # Queue is busy — collect callback preference and create a task.
        call.set_task(
            "callback",
            objective=(
                "Our scheduling team is currently busy. Collect the patient's preferred "
                "callback window so a scheduler can call them back."
            ),
            checklist=[
                guava.Say(
                    "Our scheduling team is with other patients right now. "
                    "I'll arrange for a scheduler to call you back."
                ),
                guava.Field(
                    key="preferred_callback",
                    description=(
                        "Ask when would be a good time for our scheduling team to call "
                        "them back. Capture their preferred day and time of day."
                    ),
                    field_type="text",
                    required=True,
                ),
            ],
        )


@agent.on_task_complete("callback")
def create_reschedule_task(call: guava.Call) -> None:
    patient_name = call.get_variable("patient_name")
    appointment = call.get_variable("appointment")
    preferred_callback = call.get_field("preferred_callback")

    results = {
        "timestamp": datetime.utcnow().isoformat() + "Z",
        "agent": "Alex",
        "organization": "Bright Valley Medical Center",
        "use_case": "appointment_reminder_reschedule",
        "fields": {
            "patient_name": patient_name,
            "original_appointment": appointment,
            "preferred_callback": preferred_callback,
        },
    }
    print(json.dumps(results, indent=2))
    logger.info("Reschedule details captured.")

    try:
        connect_client.start_task_contact(
            InstanceId=os.environ["CONNECT_INSTANCE_ID"],
            ContactFlowId=os.environ["CONNECT_CONTACT_FLOW_ID"],
            Name=f"Reschedule Needed — {patient_name}"[:512],
            Description=(
                f"Patient: {patient_name}\n"
                f"Original appointment: {appointment}\n"
                f"Preferred callback: {preferred_callback}\n"
                "Action: Call patient back to reschedule their appointment."
            )[:4096],
            References={
                "source": {"Value": "guava_appointment_reminder", "Type": "STRING"},
            },
            Attributes={
                "patient_name": patient_name,
                "original_appointment": appointment,
                "preferred_callback": preferred_callback or "",
            },
        )
        logger.info("Amazon Connect reschedule task created successfully.")
    except Exception as e:
        logger.error("Failed to create Amazon Connect task: %s", e)

    call.hangup(
        "Let the patient know a scheduling specialist will call them back at their "
        "preferred time. Thank them for their patience and wish them a great day."
    )


if __name__ == "__main__":
    logging_utils.configure_logging()

    parser = argparse.ArgumentParser(
        description=(
            "Outbound appointment reminder with smart escalation for Bright Valley Medical Center. "
            "Confirms the appointment, transfers to a live scheduler if one is available, or "
            "creates an Amazon Connect callback task if the queue is busy."
        )
    )
    parser.add_argument("phone", help="Patient phone number in E.164 format (e.g. +15551234567)")
    parser.add_argument("--name", required=True, help="Full name of the patient")
    parser.add_argument(
        "--appointment",
        default="tomorrow at 9:00 AM",
        help="Appointment date and time string (default: 'tomorrow at 9:00 AM')",
    )
    args = parser.parse_args()

    logger.info(
        "Calling %s (%s) — appointment: %s",
        args.name,
        args.phone,
        args.appointment,
    )

    agent.outbound_phone(
        from_number=os.environ["GUAVA_AGENT_NUMBER"],
        to_number=args.phone,
        variables={"patient_name": args.name, "appointment": args.appointment},
    ).run()

    # Send an SMS confirmation if the patient confirmed their appointment.
    if call_state["appointment_confirmed"]:
        try:
            guava.Client().send_sms(
                from_number=os.environ["GUAVA_AGENT_NUMBER"],
                to_number=args.phone,
                message=(
                    f"Hi {args.name}, this is Bright Valley Medical Center confirming "
                    f"your appointment on {args.appointment}. Please arrive 15 minutes early "
                    "and bring your insurance card and photo ID. Reply STOP to opt out."
                ),
            )
            logger.info("SMS confirmation sent to %s.", args.phone)
        except Exception as e:
            logger.error("Failed to send SMS confirmation: %s", e)

Questions? hi@goguava.ai