Step-by-Step: Building a Meeting-Booking Agent in Oracle AI Database Private Agent Factory

This walkthrough assumes a working Agent Factory installation (Quickstart mode is fine) with an LLM configured, for example, local Ollama running llama3.1:8b. The agent will: read an email, decide whether it’s a meeting request, extract the details, check calendar availability, and draft or book the meeting.

Overview of what I’ll build

[Chat/API Input] → [Agent (LLM + instructions)]
                        │
        ┌───────────────┼───────────────┐
   [Email MCP tool]  [Calendar MCP tool]  [Prompted reasoning]

Agent Factory has no built-in Gmail/Outlook connector, so email and calendar arrive as tools via an MCP server (or OpenAPI upload). That’s Step 1 and the only part requiring code, roughly 60 lines of Python. Everything after is no-code in the UI.

Step 1 — Create an MCP server exposing email + calendar tools

Agent Factory integrates external systems through MCP servers: small web services that expose typed functions the agent can call. The docs recommend FastMCP (Python) and even provide a template (see “Build Your Own MCP Server” in the product docs).

On your Mac (outside the container plus “Mac” because it is my OS) run the following command:

pip install fastmcp google-api-python-client google-auth-oauthlib
meeting-agent % pip install fastmcp google-api-python-client google-auth-oauthlib
Collecting fastmcp
  Downloading fastmcp-3.4.4-py3-none-any.whl.metadata (8.5 kB)
Collecting google-api-python-client
  Downloading google_api_python_client-2.198.0-py3-none-any.whl.metadata (7.0 kB)
Collecting google-auth-oauthlib
  Downloading google_auth_oauthlib-1.4.0-py3-none-any.whl.metadata (2.6 kB)
Collecting fastmcp-slim==3.4.4 (from fastmcp-slim[client,server]==3.4.4->fastmcp)
  Downloading fastmcp_slim-3.4.4-py3-none-any.whl.metadata (10 kB)
Collecting platformdirs>=4.0.0 (from fastmcp-slim==3.4.4->fastmcp-slim[client,server]==3.4.4->fastmcp)
  Downloading platformdirs-4.10.0-py3-none-any.whl.metadata (5.5 kB)
Collecting pydantic-settings>=2.0.0 (from fastmcp-slim==3.4.4->fastmcp-slim[client,server]==3.4.4->fastmcp)
  Downloading pydantic_settings-2.14.2-py3-none-any.whl.metadata (3.4 kB)
Collecting pydantic>=2.11.7 (from pydantic[email]>=2.11.7->fastmcp-slim==3.4.4->fastmcp-slim[client,server]==3.4.4->fastmcp)
  Downloading pydantic-2.13.4-py3-none-any.whl.metadata (109 kB)
Collecting python-dotenv>=1.1.0 (from fastmcp-slim==3.4.4->fastmcp-slim[client,server]==3.4.4->fastmcp)
  Downloading python_dotenv-1.2.2-py3-none-any.whl.metadata (27 kB)
Collecting rich>=13.9.4 (from fastmcp-slim==3.4.4->fastmcp-slim[client,server]==3.4.4->fastmcp)

....

Downloading pycparser-3.0-py3-none-any.whl (48 kB)
Installing collected packages: pyperclip, websockets, urllib3, uritemplate, uncalled-for, typing-extensions, rpds-py, pyyaml, python-multipart, python-dotenv, pyparsing, pyjwt, pygments, pycparser, pyasn1, protobuf, platformdirs, pathable, packaging, oauthlib, more-itertools, mdurl, jsonref, jaraco.context, idna, httpx-sse, h11, griffelib, exceptiongroup, docstring-parser, dnspython, click, charset_normalizer, certifi, caio, cachetools, beartype, attrs, annotated-types, uvicorn, typing-inspection, requests, referencing, pydantic-core, pyasn1-modules, py-key-value-aio, proto-plus, opentelemetry-api, markdown-it-py, jaraco.functools, jaraco.classes, httplib2, httpcore, googleapis-common-protos, email-validator, cffi, anyio, aiofile, watchfiles, starlette, rich, requests-oauthlib, pydantic, keyring, jsonschema-specifications, jsonschema-path, httpx, cryptography, sse-starlette, rich-rst, pydantic-settings, openapi-pydantic, jsonschema, joserfc, google-auth, mcp, google-auth-oauthlib, google-auth-httplib2, google-api-core, fastmcp-slim, cyclopts, authlib, google-api-python-client, fastmcp
Successfully installed aiofile-3.11.1 annotated-types-0.7.0 anyio-4.14.2 attrs-26.1.0 authlib-1.7.2 beartype-0.22.9 cachetools-7.1.4 caio-0.9.25 certifi-2026.6.17 cffi-2.1.0 charset_normalizer-3.4.9 click-8.4.2 cryptography-49.0.0 cyclopts-4.21.0 dnspython-2.8.0 docstring-parser-0.18.0 email-validator-2.3.0 exceptiongroup-1.3.1 fastmcp-3.4.4 fastmcp-slim-3.4.4 google-api-core-2.31.0 google-api-python-client-2.198.0 google-auth-2.56.0 google-auth-httplib2-0.4.0 google-auth-oauthlib-1.4.0 googleapis-common-protos-1.75.0 griffelib-2.1.0 h11-0.16.0 httpcore-1.0.9 httplib2-0.32.0 httpx-0.28.1 httpx-sse-0.4.3 idna-3.18 jaraco.classes-3.4.0 jaraco.context-6.1.2 jaraco.functools-4.6.0 joserfc-1.7.3 jsonref-1.1.0 jsonschema-4.26.0 jsonschema-path-0.5.0 jsonschema-specifications-2025.9.1 keyring-25.7.0 markdown-it-py-4.2.0 mcp-1.28.1 mdurl-0.1.2 more-itertools-11.1.0 oauthlib-3.3.1 openapi-pydantic-0.5.1 opentelemetry-api-1.43.0 packaging-26.2 pathable-0.6.0 platformdirs-4.10.0 proto-plus-1.28.1 protobuf-7.35.1 py-key-value-aio-0.4.5 pyasn1-0.6.4 pyasn1-modules-0.4.2 pycparser-3.0 pydantic-2.13.4 pydantic-core-2.46.4 pydantic-settings-2.14.2 pygments-2.20.0 pyjwt-2.13.0 pyparsing-3.3.2 pyperclip-1.11.0 python-dotenv-1.2.2 python-multipart-0.0.32 pyyaml-6.0.3 referencing-0.37.0 requests-2.34.2 requests-oauthlib-2.0.0 rich-15.0.0 rich-rst-2.1.0 rpds-py-2026.6.3 sse-starlette-3.4.5 starlette-1.3.1 typing-extensions-4.16.0 typing-inspection-0.4.2 uncalled-for-0.3.2 uritemplate-4.2.0 urllib3-2.7.0 uvicorn-0.51.0 watchfiles-1.2.0 websockets-16.1

Create meeting_mcp.py with four tools:

"""
Meeting-booking MCP server for Oracle Private Agent Factory.

Serves MOCK email/calendar data over Streamable HTTP on localhost:8765.
HTTPS is provided by the Cloudflare quick tunnel in front of it:

    Terminal 1:  python meeting_mcp.py
    Terminal 2:  cloudflared tunnel --url http://localhost:8765

Register in Agent Factory (Settings -> MCP Servers) as:
    https://<your-tunnel-url>.trycloudflare.com/mcp     <-- note /mcp, not /sse
    Authentication mode: Direct

Later, replace the mock sections with real Gmail / Google Calendar API
calls — the spots are marked with  # TODO(real API).
"""

import datetime
import json

from fastmcp import FastMCP

mcp = FastMCP("meeting-tools")

# ---------------------------------------------------------------------------
# Mock data — realistic enough to exercise every branch of the agent flow
# ---------------------------------------------------------------------------
MOCK_EMAILS = [
    {
        "message_id": "msg-001",
        "sender": "anna.svensson@example.com",
        "subject": "Catch up next week?",
        "body": (
            "Hi Bruno, could we find 30 minutes early next week to discuss "
            "the Q3 roadmap? Tuesday or Wednesday morning works best for me. "
            "Thanks! Anna"
        ),
    },
    {
        "message_id": "msg-002",
        "sender": "newsletter@technews.example.com",
        "subject": "This week in AI",
        "body": "Top stories: new model releases, chip shortages easing...",
    },
    {
        "message_id": "msg-003",
        "sender": "peter.karlsson@example.com",
        "subject": "Quick sync",
        "body": "Hey, we should talk sometime. Whenever works for you. /Peter",
    },
]

MOCK_DRAFTS = []  # created drafts accumulate here so you can inspect them


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@mcp.tool()
def list_recent_emails(max_results: int = 10) -> str:
    """List recent unread emails. Returns JSON array of objects with
    message_id, sender, subject, and body."""
    # TODO(real API): replace with Gmail users.messages.list + get
    return json.dumps(MOCK_EMAILS[:max_results])


@mcp.tool()
def get_free_slots(date_from: str, date_to: str, duration_minutes: int = 30) -> str:
    """Return free calendar slots between date_from and date_to (ISO dates,
    e.g. 2026-07-20). Slots are returned as JSON with ISO start/end times
    in Europe/Stockholm time."""
    # TODO(real API): replace with Google Calendar freebusy query
    try:
        start = datetime.date.fromisoformat(date_from)
    except ValueError:
        return json.dumps({"error": f"invalid date_from: {date_from}"})

    slots = []
    day = start
    for _ in range(3):  # propose slots across three working days
        if day.weekday() < 5:  # Mon-Fri
            for hour in (9, 10, 14):
                s = datetime.datetime.combine(day, datetime.time(hour, 0))
                e = s + datetime.timedelta(minutes=duration_minutes)
                slots.append({"start": s.isoformat(), "end": e.isoformat()})
        day += datetime.timedelta(days=1)
    return json.dumps(slots[:6])


@mcp.tool()
def create_calendar_event(title: str, start_iso: str, end_iso: str,
                          attendee_email: str) -> str:
    """Create a calendar event. Only call this after the user explicitly
    confirms a specific slot. Returns a confirmation with an event link."""
    # TODO(real API): replace with Google Calendar events.insert
    return json.dumps({
        "status": "created (mock)",
        "title": title,
        "start": start_iso,
        "end": end_iso,
        "attendee": attendee_email,
        "link": "https://calendar.example.com/event/mock-123",
    })


@mcp.tool()
def draft_reply(message_id: str, body: str) -> str:
    """Create a DRAFT reply to the email with the given message_id.
    The draft is not sent; a human reviews and sends it."""
    # TODO(real API): replace with Gmail users.drafts.create
    MOCK_DRAFTS.append({"message_id": message_id, "body": body})
    return json.dumps({
        "status": "draft created (mock)",
        "message_id": message_id,
        "draft_number": len(MOCK_DRAFTS),
    })


@mcp.tool()
def list_drafts() -> str:
    """List all drafts created so far in this session (for inspection)."""
    return json.dumps(MOCK_DRAFTS)


# ---------------------------------------------------------------------------
# Serve over Streamable HTTP — endpoint is /mcp
# The Cloudflare tunnel provides HTTPS in front.
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    try:
        mcp.run(transport="http", host="127.0.0.1", port=8765)
    except (ValueError, TypeError, KeyError):
        # older fastmcp versions use the alias "streamable-http"
        mcp.run(transport="streamable-http", host="127.0.0.1", port=8765)

Fill in the API calls for your provider (Gmail + Google Calendar is the most documented path; complete the OAuth consent once locally and store the token). Run it:

python meeting_mcp.py

Note two design choices baked in: draft_reply instead of send_reply (human-in-the-loop from day one), and availability logic in code rather than asking the LLM to do calendar math.

Step 2 — Register the MCP server in Agent Factory (and survive its security policies)

This step looked like a two-minute form. It turned into the most instructive part of the whole build, because Agent Factory ships with strict security defaults that make perfect sense for an enterprise deployment, and that you will collide with, one by one, on a laptop. Here is the collision sequence, so you don’t have to discover it yourself.

Attempt 1 — plain HTTP. In the left sidebar, go to Settings → MCP Servers → Add MCP server. I entered the same host alias that worked for Ollama, http://host.containers.internal:8765/sse (Agent Factory runs in a container, so localhost would point at the container itself). Result:

400 Bad Request: MCP server URL must use https (HTTP is disabled by admin policy)

Agent Factory refuses plain-HTTP MCP endpoints. Fair enough.

Attempt 2 — HTTPS with a self-signed certificate. FastMCP runs on uvicorn, which accepts TLS certificates, so I generated one:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
  -days 365 -nodes -subj "/CN=host.containers.internal"

…restarted the server over HTTPS, and registered https://host.containers.internal:8765/sse. New error:

400 Bad Request: MCP server URL resolves to a private or non-routable network address and is not allowed

A second, independent policy: SSRF protection. Agent Factory refuses MCP URLs that resolve to private IP ranges, which is exactly what the container-to-host alias is. Also fair, and also inconvenient.

Attempt 3 — a Cloudflare quick tunnel (this one works). A quick tunnel gives your local server a public HTTPS hostname in seconds, no Cloudflare account required, satisfying both policies at once, and since the tunnel terminates TLS, the local server can go back to plain HTTP (delete the cert config).

brew install cloudflared
cloudflared tunnel --url http://localhost:8765


Attempt 3½ — and switch the transport to Streamable HTTP. Registering the tunnel URL with the /sse path got the server added, but the status pill stubbornly said disconnected, and the tunnel log showed the SSE stream being canceled. Long-lived SSE streams are fragile through proxies; the MCP ecosystem has moved to the Streamable HTTP transport for exactly this reason, and Oracle’s own FAQ recommends it. That’s why the code above uses transport="http", the server listens at the /mcp path instead of /sse. Sanity-check it locally:

curl -X POST http://localhost:8765/mcp -H "Content-Type: application/json" \
  --max-time 3 -d '{}'

A JSON-RPC response (even an error about the Accept header) means the server is speaking protocol. Then register in Agent Factory:

  • Server name: meeting-tools
  • Server URL: https://<your-tunnel-words>.trycloudflare.com/mcp
  • Authentication mode: Direct

And finally: connected, with all five tools discovered.

The honest caveats of the tunnel shortcut. The quick-tunnel URL changes every time cloudflared restarts (update the entry when it does), and your traffic transits Cloudflare on a technically-public, practically-unguessable URL. With mock data, that is a non-issue and a great development setup. Before pointing this at a real inbox, switch to a proper certificate on a stable endpoint, add a bearer token to the MCP server, or relax the network policy for your private deployment, this is a Private Agent Factory, after all, and in production the whole point is that nothing needs to leave your network.

Step 3 — Tune the prompts in Prompt Lab

Before wiring the flow, use Prompt Lab (sidebar → Utilities) to iterate on the two prompts that carry all the judgment, testing them against sample emails.

Classification — decides if an email is a meeting request:

You are an email classifier. Given the email below, respond with exactly one
word: MEETING_REQUEST, RESCHEDULE, CANCELLATION, or OTHER.

Email:
{email_body}

Extraction — pulls structured details:

Extract meeting details from this email. Respond ONLY with JSON:
{"requester_email": "...", "duration_minutes": 30,
 "date_constraints": "...", "topic": "...", "timezone": "unknown"}
Use "unknown" for anything not stated. Do not guess.

Email:
{email_body}

The “unknown, do not guess” rule is what later routes ambiguous emails to a clarifying reply instead of a wrong booking. My mock inbox includes a deliberately hopeless “whenever works for you” email precisely to exercise that branch.

Step 4 — Build the flow in Agent Builder

1. Open Agent Builder (sidebar → Utilities) — a drag-and-drop canvas. Select LLM to use: click the llm_model_entry (ollama) option in the dropdown to confirm it.

  • First “Write here” box (above Agent description, this is the system instructions field): paste the full instruction block
You are a meeting scheduling assistant.

You have EXACTLY these five tools and no others:
- list_recent_emails
- get_free_slots
- draft_reply
- create_calendar_event
- list_drafts

There is NO classify tool. Classification is done by your own reasoning,
not by a tool call. Never invent tool names.

When asked to process the inbox, follow these steps in order:

1. Call list_recent_emails.

2. For each email, decide by your own reasoning (no tool call) which
   category it belongs to: MEETING_REQUEST, RESCHEDULE, CANCELLATION,
   or OTHER.
   - OTHER (newsletters, notifications, anything not about scheduling):
     ignore it completely.
   - RESCHEDULE or CANCELLATION: only mention it in your final summary.
     Never modify existing events.

3. For each MEETING_REQUEST, extract from the email text: the requester's
   email address, desired duration in minutes (default 30 if not stated),
   date constraints, topic, and timezone.
   - If the date constraints are too vague to act on (for example
     "whenever works for you") or essential details are missing:
     call draft_reply with a short, friendly message asking the sender
     to suggest specific days or times. Then stop processing that email.

4. If the date constraints are clear: call get_free_slots with a
   date_from and date_to covering the requested period (ISO format,
   e.g. 2026-07-20) and the duration.

5. Call draft_reply to that sender, proposing the top 3 available slots
   in a friendly, professional tone. Sign the draft "Bruno".

6. NEVER call create_calendar_event on your own. Only call it when the
   user explicitly confirms a specific slot, for example
   "book it — Tuesday 10:00 with anna@example.com".

7. After processing all emails, reply to the user with a short summary:
   how many emails you found, how each was categorized, which drafts you
   created, and which need clarification. Keep the summary brief.
  • Agent description: something like Reads inbox, proposes meeting slots, drafts replies — this matters more later if you add sub-agents, but fill it meaningfully.
  • Prompt: leave empty for now — this is for a fixed prompt; you’ll drive the agent from chat input instead.
  • Temperature: it’s at 0.01, which is actually great for this use case — low temperature = consistent classification and tool-calling. Leave it.

2. Add the MCP Server node. In the Components panel on the left, type mcp in the Search components box (it lives under a Tools category further down the list you’re seeing). Drag the MCP Server node onto the canvas. Click it and select meeting-tools from its dropdown, it should show the five discovered tools.

3. Connect MCP → Agent. Drag a line from the MCP Server node’s output connector to the Agent node’s Agent/Tools port: on your Agent node that’s the connector labeled Agent at the bottom right of the node (the dark dot). The Message port is for chat traffic; the Agent port is where tools and sub-agents attach.

4. Add a Chat input node. From Components → INPUTS (visible at the bottom of your panel), drag Chat input onto the canvas and connect its output to the Agent node’s Message port. This is what lets you talk to the agent in the playground. Connect the chat input to the agent node so you can drive it conversationally and also a component as chat output from the agent.

With an 8B local model, be explicit and numbered exactly like this: smaller models reward unambiguous instructions.

Step 5 — Test in the playground

Agent Builder includes a chat playground. Send:

“Process my inbox for meeting requests.”

and watch the tool invocations fire. Test the edge cases deliberately: Anna’s “Tuesday or Wednesday morning” should produce slot proposals; Peter’s “whenever works for you” should produce a clarifying draft, not a booking; the newsletter should be classified OTHER and skipped. Then say “book it — Tuesday 10:00 with anna.svensson@example.com” — only now should create_calendar_event fire. Inspect what the agent wrote with the list_drafts tool.

Step 6 — Publish

When the flow behaves, publish it. Agent Factory exposes the published agent at a REST endpoint (copy the Agent API Endpoint URL from the flow), so a small cron job or an email webhook can invoke “process the inbox” every 15 minutes, turning the on-demand assistant into an automatic one. Published flows appear under My Custom Flows, and from release 26.4 you can export them as password-protected .paf files to back up or promote to another environment.

Conclusion

What started as “build an agent that books meetings” turned out to be two projects in one: the agent itself, and the education of getting a real integration running inside a security-conscious enterprise platform. Both were worth it.

The agent part validated the core design principle: move decisions out of the model and into structure. The LLM does what it’s genuinely good at, which is reading messy human email and writing friendly replies, while everything that must be correct lives in ordinary, auditable code: calendar math in Python, replies as drafts rather than sends, and booking gated behind an explicit human confirmation. A local llama3.1:8b, guided by explicit numbered instructions, handled the triage correctly on a MacBook with no API fees and no email content leaving the machine.

The platform part taught the more transferable lesson. Every obstacle along the way, from HTTPS-only MCP endpoints to the refusal of private network addresses to the switch from SSE to Streamable HTTP, felt like friction on a laptop. But each one is exactly the constraint you’d want enforced the day this stops being a demo and starts reading a real inbox. Private Agent Factory’s defaults are opinionated in the right direction. The workarounds I used for development (the Cloudflare quick tunnel, mock data, Direct auth) are scaffolding to be dismantled on the way to production, not shortcuts to keep.

And that’s the wider point about this way of building: the only code in the entire project is the ~100-line MCP wrapper. Every behavioral decision, such as what counts as a meeting request, how to handle vague senders, and when booking is allowed, lives in instructions I can edit in a text box and re-test in seconds. That iteration speed is what makes it realistic to extend this pattern to the obvious next candidates: invoice triage, support routing, meeting-prep briefs, and half the other repetitive workflows in any inbox.

Now if you’ll excuse me: my agent just drafted a reply proposing Tuesday at 10:00 and I did not have the need to look at my calendar 🙂

Related posts