Skip to content
Learn Agentic AI11 min read0 views

Building a Library Services Agent: Catalog Search, Hold Management, and Program Registration

Build an AI agent for public libraries that searches the catalog, places and manages holds, handles account inquiries, and helps patrons discover library programs and events.

The Modern Library Agent

Public libraries are among the most-used government services. A mid-size library system handles thousands of patron interactions daily: catalog searches, hold requests, account questions, program registrations, and reference inquiries. Many of these are repetitive and well-suited to automation — "Do you have this book?" "When is my hold ready?" "What programs are happening this week for kids?"

An AI agent can handle these routine interactions, freeing librarians to focus on the work that requires human expertise: readers' advisory, research assistance, community programming, and helping patrons with complex information needs. The agent is not a replacement for the librarian — it is a force multiplier.

Modeling the Library Catalog

Library systems use standardized formats like MARC (Machine-Readable Cataloging) and communicate through protocols like SIP2 and Z39.50. For our agent, we abstract these into a clean data model.

from dataclasses import dataclass, field
from enum import Enum
from datetime import date


class MaterialType(Enum):
    BOOK = "book"
    EBOOK = "ebook"
    AUDIOBOOK = "audiobook"
    DVD = "dvd"
    MAGAZINE = "magazine"
    MUSIC_CD = "music_cd"
    VIDEO_GAME = "video_game"


class ItemStatus(Enum):
    AVAILABLE = "available"
    CHECKED_OUT = "checked_out"
    ON_HOLD = "on_hold"
    IN_TRANSIT = "in_transit"
    PROCESSING = "processing"
    LOST = "lost"


@dataclass
class CatalogItem:
    item_id: str
    title: str
    author: str
    material_type: MaterialType
    isbn: str = ""
    publication_year: int = 0
    subjects: list[str] = field(default_factory=list)
    summary: str = ""
    page_count: int = 0
    language: str = "English"
    series: str | None = None
    series_number: int | None = None
    audience: str = "adult"  # adult, teen, juvenile, children


@dataclass
class ItemCopy:
    copy_id: str
    item_id: str
    branch: str
    status: ItemStatus
    due_date: date | None = None
    call_number: str = ""
    location: str = ""  # fiction, nonfiction, reference, etc.


@dataclass
class PatronAccount:
    patron_id: str
    name: str
    email: str
    phone: str = ""
    home_branch: str = ""
    items_checked_out: int = 0
    items_on_hold: int = 0
    fines_owed: float = 0.0
    card_expiration: date | None = None

Catalog Search Engine

The search engine must handle natural language queries like "mystery novels set in Japan" or "picture books about dinosaurs" and translate them into structured catalog searches.

from openai import OpenAI
import json

client = OpenAI()

CATALOG_SEARCH_PROMPT = """Extract search parameters from the patron's
catalog query.

Return JSON with any of these fields (omit if not mentioned):
- "title": string (exact or partial title)
- "author": string (author name)
- "subject": string (topic/genre)
- "material_type": "book" | "ebook" | "audiobook" | "dvd" | "magazine"
- "audience": "adult" | "teen" | "juvenile" | "children"
- "language": string
- "series": string (series name)
- "keyword": string (general search term)
- "available_only": boolean
"""


def parse_catalog_query(patron_query: str) -> dict:
    """Extract structured search filters from natural language."""
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": CATALOG_SEARCH_PROMPT},
            {"role": "user", "content": patron_query},
        ],
        response_format={"type": "json_object"},
        temperature=0.0,
    )
    return json.loads(response.choices[0].message.content)


def search_catalog(
    filters: dict,
    catalog: list[CatalogItem] = None,
    copies: list[ItemCopy] = None,
) -> list[dict]:
    """Search the catalog using extracted filters."""
    results = catalog or []

    if "title" in filters:
        q = filters["title"].lower()
        results = [i for i in results if q in i.title.lower()]

    if "author" in filters:
        q = filters["author"].lower()
        results = [i for i in results if q in i.author.lower()]

    if "subject" in filters:
        q = filters["subject"].lower()
        results = [
            i for i in results
            if any(q in s.lower() for s in i.subjects)
        ]

    if "material_type" in filters:
        mt = filters["material_type"].lower()
        results = [i for i in results if i.material_type.value == mt]

    if "audience" in filters:
        aud = filters["audience"].lower()
        results = [i for i in results if i.audience == aud]

    if "language" in filters:
        lang = filters["language"].lower()
        results = [i for i in results if i.language.lower() == lang]

    # Enrich with availability
    enriched = []
    for item in results[:20]:
        item_copies = [c for c in (copies or []) if c.item_id == item.item_id]
        available_copies = [c for c in item_copies if c.status == ItemStatus.AVAILABLE]

        if filters.get("available_only") and not available_copies:
            continue

        enriched.append({
            "title": item.title,
            "author": item.author,
            "type": item.material_type.value,
            "year": item.publication_year,
            "total_copies": len(item_copies),
            "available_copies": len(available_copies),
            "branches_available": list({c.branch for c in available_copies}),
            "earliest_return": min(
                (c.due_date for c in item_copies if c.due_date),
                default=None,
            ),
            "item_id": item.item_id,
        })

    return enriched

Hold Management

Placing and managing holds is one of the most common patron requests. The agent needs to handle hold placement, position tracking, and suspension.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

from datetime import datetime, timedelta
import uuid


@dataclass
class Hold:
    hold_id: str
    patron_id: str
    item_id: str
    pickup_branch: str
    placed_date: datetime
    status: str = "waiting"  # waiting, ready, expired, cancelled
    queue_position: int = 0
    estimated_wait_days: int | None = None
    ready_date: datetime | None = None
    expiration_date: datetime | None = None
    suspended_until: date | None = None


class HoldManager:
    """Manage patron holds on catalog items."""

    def __init__(self, db):
        self.db = db

    async def place_hold(
        self, patron_id: str, item_id: str, pickup_branch: str
    ) -> Hold:
        """Place a hold on a catalog item."""
        # Check patron eligibility
        patron = await self.db.get_patron(patron_id)
        if patron.fines_owed > 10.00:
            raise ValueError(
                "Hold cannot be placed with fines over $10.00. "
                f"Current balance: ${patron.fines_owed:.2f}"
            )

        # Check existing holds limit
        if patron.items_on_hold >= 25:
            raise ValueError("Maximum of 25 holds reached.")

        # Get current hold queue length
        existing_holds = await self.db.get_holds_for_item(item_id)
        queue_position = len(existing_holds) + 1

        # Estimate wait time based on copies and queue position
        copies = await self.db.get_copies(item_id)
        total_copies = len(copies)
        avg_checkout_days = 21
        estimated_wait = (queue_position / max(total_copies, 1)) * avg_checkout_days

        hold = Hold(
            hold_id=str(uuid.uuid4())[:8],
            patron_id=patron_id,
            item_id=item_id,
            pickup_branch=pickup_branch,
            placed_date=datetime.utcnow(),
            queue_position=queue_position,
            estimated_wait_days=int(estimated_wait),
        )

        await self.db.save_hold(hold)
        return hold

    async def get_patron_holds(self, patron_id: str) -> list[dict]:
        """Get all active holds for a patron with status details."""
        holds = await self.db.get_holds_by_patron(patron_id)
        results = []

        for hold in holds:
            item = await self.db.get_catalog_item(hold.item_id)
            results.append({
                "hold_id": hold.hold_id,
                "title": item.title,
                "author": item.author,
                "status": hold.status,
                "queue_position": hold.queue_position,
                "estimated_wait_days": hold.estimated_wait_days,
                "pickup_branch": hold.pickup_branch,
                "ready_date": hold.ready_date.isoformat() if hold.ready_date else None,
                "expires": hold.expiration_date.isoformat() if hold.expiration_date else None,
            })

        return results

Library Programs and Events

Libraries run extensive programming — storytimes, book clubs, author visits, maker space workshops, ESL classes, and digital literacy training. The agent helps patrons discover and register for events.

@dataclass
class LibraryEvent:
    event_id: str
    title: str
    description: str
    branch: str
    event_date: datetime
    duration_minutes: int
    audience: str  # children, teen, adult, all_ages
    category: str  # storytime, book_club, workshop, author, technology
    registration_required: bool = False
    max_attendees: int | None = None
    current_registrations: int = 0
    cost: float = 0.0  # almost always free


def find_upcoming_events(
    branch: str | None = None,
    audience: str | None = None,
    category: str | None = None,
    days_ahead: int = 14,
    events: list[LibraryEvent] = None,
) -> list[dict]:
    """Find upcoming library events with optional filtering."""
    now = datetime.utcnow()
    cutoff = now + timedelta(days=days_ahead)

    results = events or []
    results = [e for e in results if now <= e.event_date <= cutoff]

    if branch:
        results = [e for e in results if e.branch.lower() == branch.lower()]
    if audience:
        results = [
            e for e in results
            if e.audience == audience or e.audience == "all_ages"
        ]
    if category:
        results = [e for e in results if e.category.lower() == category.lower()]

    results.sort(key=lambda e: e.event_date)

    return [
        {
            "title": e.title,
            "branch": e.branch,
            "date": e.event_date.strftime("%A, %B %d at %I:%M %p"),
            "duration": f"{e.duration_minutes} minutes",
            "audience": e.audience,
            "category": e.category,
            "registration_required": e.registration_required,
            "spots_available": (
                e.max_attendees - e.current_registrations
                if e.max_attendees else "Unlimited"
            ),
            "free": e.cost == 0,
        }
        for e in results[:15]
    ]

FAQ

The agent builds a reading profile from the patron's checkout history and hold patterns. If a patron has checked out five cozy mysteries in the past year, the agent can suggest similar titles, new releases in the genre, or adjacent genres like domestic suspense. It uses the same approach as recommendation systems: collaborative filtering (patrons who read X also read Y) combined with content-based filtering (same author, subject, or series). The agent presents recommendations with brief explanations: "Since you enjoyed The Thursday Murder Club, you might like these other mystery novels featuring older protagonists."

How does the agent handle patrons with accessibility needs?

The agent proactively surfaces alternative formats. When a patron searches for a title, results include all available formats — print, large print, audiobook, e-book, and Braille if available. If a patron has previously checked out only audiobooks or large print editions, the agent defaults to showing those formats first. For library events, the agent includes accessibility information: wheelchair access, ASL interpretation availability, and whether assistive listening devices are provided.

Can the agent help manage interlibrary loan requests?

Yes. When a patron searches for a title that is not in the local catalog, the agent checks regional consortium catalogs and offers to place an interlibrary loan (ILL) request. It explains the process: "This title is not in our collection, but it is available at County Library. I can request it for you — ILL requests typically take 5-10 business days. There is no charge." The agent tracks the ILL status and notifies the patron when the item arrives at their pickup branch.


#GovernmentAI #LibraryServices #CatalogSearch #PublicLibraries #CommunityServices #AgenticAI #LearnAI #AIEngineering

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.