How to Extend the Functionality of AI Agents for Beginners: A Complete Guide

You can extend the functionality of AI Agents for Beginners by adding custom tools with @ai_function, implementing middleware for request/response processing, integrating memory providers for persistent state, and building multi-agent workflows using WorkflowBuilder.

This article walks you through the exact architecture and code patterns used in the Microsoft AI Agents for Beginners repository. Whether you want to add sentiment analysis, database-backed memory, or complex business workflows, you'll learn how to extend the codebase while maintaining its pedagogical structure.


Understanding the Extension Architecture

The AI Agents for Beginners repository is built around the Microsoft Agent Framework (MAF), a Python library that provides structured components for building AI agents. Each lesson in the repository demonstrates specific capabilities through self-contained examples.

Core Extension Points

Component Purpose Extension Method
Tools LLM-callable functions Add @ai_function decorated functions
Middleware Pre/post-processing hooks Implement @executor decorated functions
Memory providers Persistent state across turns Configure ChatMessageStore or custom providers
Workflows Multi-agent orchestration Use WorkflowBuilder with conditional edges
Agents Encapsulated LLM + tools Create new AgentExecutor instances

These components are demonstrated in 14-microsoft-agent-framework/code-samples/hotel_booking_workflow_sample.py, which serves as the reference implementation for all extensions.


Step 1: Creating a New Lesson Module

To extend the functionality of AI Agents for Beginners, start by creating a properly structured lesson folder that matches the repository's pedagogical pattern.

Folder Structure

mkdir 16-sentiment-analysis
mkdir 16-sentiment-analysis/code_samples
mkdir 16-sentiment-analysis/images

Required Files

File Purpose
README.md Concept explanation and learning objectives
code_samples/16-sentiment-python-agent-framework.ipynb Interactive Jupyter notebook
code_samples/sentiment_demo.py Standalone verification script
images/ Workflow diagrams and architecture visuals

Update the top-level README.md or AGENTS.md to include your new lesson in the course index. The repository's GitHub Actions workflow (.github/workflows/co-op-translator.yml) will automatically translate your new lesson into 50+ languages once merged.


Step 2: Building Custom Tools with @ai_function

Tools are the primary extension mechanism for adding capabilities to agents. The @ai_function decorator from the Microsoft Agent Framework converts Python functions into LLM-callable tools.

Basic Tool Pattern

from typing import Annotated
from agent_framework import ai_function
import json

@ai_function(description="Detects sentiment of user message.")
def analyze_sentiment(
    message: Annotated[str, "User message to analyze"]
) -> str:
    """Simple keyword-based sentiment analysis."""
    lower = message.lower()
    if any(word in lower for word in ["love", "great", "awesome"]):
        result = {"sentiment": "positive", "confidence": 0.95}
    elif any(word in lower for word in ["hate", "terrible", "bad"]):
        result = {"sentiment": "negative", "confidence": 0.93}
    else:
        result = {"sentiment": "neutral", "confidence": 0.80}
    
    return json.dumps(result)

Advanced Tool Features

  • Structured outputs: Return JSON strings that match Pydantic models
  • Async support: Define async def functions for I/O-bound operations
  • Error handling: Raise exceptions that the agent framework converts to tool error messages

Tools are registered with agents through the tools parameter in chat_client.create_agent().


Step 3: Creating Agents with Custom Configuration

Agents in the Microsoft Agent Framework combine an LLM client, system instructions, tools, and output schemas into executable units.

Agent Creation Pattern

from agent_framework import AgentExecutor
from agent_framework.openai import OpenAIChatClient
from pydantic import BaseModel

# Define structured output models

class SentimentResult(BaseModel):
    sentiment: str  # "positive", "neutral", "negative"

    confidence: float

class ReplyResult(BaseModel):
    reply: str

# Initialize LLM client

chat_client = OpenAIChatClient(model_id="gpt-4o")

# Create sentiment analysis agent

sentiment_agent = AgentExecutor(
    chat_client.create_agent(
        instructions=(
            "You are a sentiment analyzer. "
            "Only call the analyze_sentiment tool and return its JSON output. "
            "Do not add conversational text."
        ),
        tools=[analyze_sentiment],  # From Step 2

        response_format=SentimentResult,
    ),
    id="sentiment_agent",
)

# Create response generation agent

reply_agent = AgentExecutor(
    chat_client.create_agent(
        instructions=(
            "You are a helpful assistant. "
            "Use the detected sentiment to adapt your tone: "
            "be enthusiastic for positive, empathetic for negative, "
            "and professional for neutral. "
            "Return a short reply in JSON format."
        ),
        response_format=ReplyResult,
    ),
    id="reply_agent",
)

Key configuration options include:

  • instructions: System prompt defining agent behavior
  • tools: List of @ai_function decorated tools
  • response_format: Pydantic model for structured outputs
  • temperature/max_tokens: Generation parameters (passed to client)

Step 4: Implementing Middleware for Cross-Cutting Concerns

Middleware provides hooks for logic that runs before or after tool calls and LLM invocations. The @executor decorator and FunctionInvocationContext enable extensions for logging, authentication, rate limiting, and observability.

Middleware Patterns

from agent_framework import executor, FunctionInvocationContext
from agent_framework.types import AgentExecutorResponse
import time

# Pre/post tool call middleware

@executor(id="timing_middleware")
async def log_execution_time(ctx: FunctionInvocationContext) -> None:
    """Logs duration of function invocations."""
    start = time.time()
    await ctx.next()  # Proceed to actual function

    duration = time.time() - start
    print(f"[Timing] {ctx.function_name} took {duration:.3f}s")

# Post-processing middleware for responses

@executor(id="audit_middleware")
async def audit_response(ctx: FunctionInvocationContext) -> None:
    """Captures agent outputs for compliance auditing."""
    await ctx.next()
    if isinstance(ctx.result, AgentExecutorResponse):
        # Log to external system

        print(f"[Audit] Agent {ctx.executor_id} output: {ctx.result.agent_run_response.text[:200]}")

Middleware is attached to agents by passing middleware=[timing_middleware, audit_middleware] to AgentExecutor or by using the FunctionInvocationContext pattern shown in hotel_booking_workflow_sample.py lines 83-96.


Step 5: Adding Memory Providers for Persistent State

Memory providers enable agents to retain information across conversation turns. The Microsoft Agent Framework supports multiple memory backends through ChatMessageStore and context_providers.

Memory Configuration Options

Provider Use Case Implementation
In-memory Testing, single-session demos Default ChatMessageStore
Mem0 Personalization, long-term user memory Mem0Provider with user ID
Custom database Enterprise persistence, audit requirements Subclass ChatMessageStore

Memory Implementation Example

from agent_framework.memory import ChatMessageStore, Mem0Provider

# Simple in-memory store with session persistence

memory_store = ChatMessageStore(max_messages=20)

# Mem0 integration for cross-session memory

mem0_provider = Mem0Provider(
    api_key=os.getenv("MEM0_API_KEY"),
    user_id="user_12345",
)

# Create agent with memory

context_agent = AgentExecutor(
    chat_client.create_agent(
        instructions="You are a helpful assistant with memory of past conversations.",
        tools=[analyze_sentiment],
    ),
    id="context_agent",
    chat_message_store_factory=lambda: memory_store,  # Per-session memory

    context_providers=[mem0_provider],  # Cross-session memory

)

Memory providers are passed to AgentExecutor via chat_message_store_factory (for conversation history) and context_providers (for additional context sources).


Step 6: Building Multi-Agent Workflows with WorkflowBuilder

The WorkflowBuilder enables complex orchestration patterns including sequential processing, conditional branching, and parallel execution (fan-out/fan-in).

Workflow Construction Pattern

from agent_framework import WorkflowBuilder, AgentExecutorRequest
from agent_framework.types import ChatMessage, Role

# Conditional routing functions

def is_negative_sentiment(response: Any) -> bool:
    """Route to escalation path for negative feedback."""
    if not isinstance(response, AgentExecutorResponse):
        return False
    try:
        result = SentimentResult.model_validate_json(response.agent_run_response.text)
        return result.sentiment == "negative"
    except Exception:
        return False

def is_positive_or_neutral(response: Any) -> bool:
    return not is_negative_sentiment(response)

# Build workflow

workflow = (
    WorkflowBuilder()
    .set_start_executor(sentiment_agent)  # First: analyze sentiment

    .add_edge(
        sentiment_agent, 
        escalated_reply_agent,  # Special handling for negative

        condition=is_negative_sentiment
    )
    .add_edge(
        sentiment_agent,
        reply_agent,  # Standard handling

        condition=is_positive_or_neutral
    )
    .add_edge(escalated_reply_agent, display)
    .add_edge(reply_agent, display)
    .build()
)

# Execute

async def run_workflow(user_message: str):
    request = AgentExecutorRequest(
        messages=[ChatMessage(Role.USER, text=user_message)],
        should_respond=True,
    )
    return await workflow.run(request)

The WorkflowBuilder pattern in hotel_booking_workflow_sample.py (lines 68-79) demonstrates:

  • Sequential execution: Default edge from one executor to next
  • Conditional edges: condition parameter for branching logic
  • Fan-out/Fan-in: Multiple edges from single executor for parallel processing

Complete Extension Example: Sentiment-Aware Support Bot

Here's a consolidated, runnable example that combines all extension patterns into a new capability you can add to the repository.

#!/usr/bin/env python3
"""
sentiment_support_bot.py
Complete example for extending AI Agents for Beginners with sentiment analysis.
Place in: 16-sentiment-analysis/code_samples/
"""

import os
import json
import asyncio
from typing import Annotated, Any
from dataclasses import dataclass

from agent_framework import (
    AgentExecutor,
    AgentExecutorRequest,
    ChatMessage,
    Role,
    WorkflowBuilder,
    WorkflowContext,
    ai_function,
    executor,
)
from agent_framework.openai import OpenAIChatClient
from agent_framework.types import AgentExecutorResponse
from dotenv import load_dotenv
from pydantic import BaseModel

# Load environment variables

load_dotenv()


# ============================================================

# 1. PYDANTIC MODELS (Structured Outputs)

# ============================================================

class SentimentResult(BaseModel):
    """Output from sentiment analysis tool."""
    sentiment: str  # "positive", "neutral", "negative"

    confidence: float
    detected_keywords: list[str]


class ReplyResult(BaseModel):
    """Final support response."""
    reply: str
    escalation_recommended: bool


# ============================================================

# 2. CUSTOM TOOLS (@ai_function)

# ============================================================

@ai_function(description="Analyzes sentiment of customer message with keyword detection.")
def analyze_sentiment(
    message: Annotated[str, "Customer message to analyze"]
) -> str:
    """Production-ready sentiment analysis (replace with Azure AI Language for accuracy)."""
    lower = message.lower()
    
    positive_keywords = ["love", "great", "awesome", "excellent", "thank", "happy"]
    negative_keywords = ["hate", "terrible", "bad", "awful", "frustrated", "angry", "worst"]
    
    detected_positive = [w for w in positive_keywords if w in lower]
    detected_negative = [w for w in negative_keywords if w in lower]
    
    if detected_positive and not detected_negative:
        result = SentimentResult(
            sentiment="positive",
            confidence=0.9 + 0.05 * len(detected_positive),
            detected_keywords=detected_positive
        )
    elif detected_negative:
        result = SentimentResult(
            sentiment="negative",
            confidence=0.85 + 0.05 * len(detected_negative),
            detected_keywords=detected_negative
        )
    else:
        result = SentimentResult(
            sentiment="neutral",
            confidence=0.75,
            detected_keywords=[]
        )
    
    return result.model_dump_json()


@ai_function(description="Looks up customer order by ID.")
def lookup_order(
    order_id: Annotated[str, "Order ID to look up"]
) -> str:
    """Stub for order lookup (replace with database query)."""
    # Simulated database response

    mock_orders = {
        "ORD-12345": {"status": "shipped", "items": ["Widget Pro"], "eta": "2 days"},
        "ORD-67890": {"status": "processing", "items": ["Gadget Max"], "eta": "5 days"},
    }
    return json.dumps(mock_orders.get(order_id, {"error": "Order not found"}))


# ============================================================

# 3. MIDDLEWARE (observability & logging)

# ============================================================

@executor(id="logging_middleware")
async def log_interactions(ctx: WorkflowContext) -> None:
    """Logs all agent interactions for debugging."""
    print(f"\n[LOG] Executing: {ctx.executor_id}")
    print(f"[LOG] Input: {ctx.input_data}")
    
    await ctx.next()  # Continue to actual execution

    
    print(f"[LOG] Output: {ctx.result}")


# ============================================================

# 4. AGENT CREATION

# ============================================================

# Initialize LLM client

chat_client = OpenAIChatClient(model_id="gpt-4o")

# Sentiment analysis agent

sentiment_agent = AgentExecutor(
    chat_client.create_agent(
        instructions=(
            "You are a sentiment analysis specialist. "
            "Use the analyze_sentiment tool to classify customer messages. "
            "Return ONLY the JSON result from the tool."
        ),
        tools=[analyze_sentiment],
        response_format=SentimentResult,
    ),
    id="sentiment_agent",
    # middleware=[logging_middleware]  # Optional

)

# Order lookup agent (for complex queries)

order_agent = AgentExecutor(
    chat_client.create_agent(
        instructions=(
            "You help customers with order inquiries. "
            "Use lookup_order when an order ID is provided. "
            "Otherwise, ask clarifying questions."
        ),
        tools=[lookup_order],
    ),
    id="order_agent",
)

# Response generation agent with tone adaptation

def make_reply_agent(sentiment: str):
    """Factory for tone-specific response agents."""
    tone_instructions = {
        "positive": "Be enthusiastic and celebratory. Use exclamation points!",
        "negative": "Be deeply empathetic. Acknowledge frustration. Offer concrete solutions.",
        "neutral": "Be professional and concise. Stick to facts.",
    }
    
    return AgentExecutor(
        chat_client.create_agent(
            instructions=(
                f"You are a support response specialist. {tone_instructions.get(sentiment, tone_instructions['neutral'])} "
                "Generate a helpful response based on the context provided. "
                "Return JSON with 'reply' and 'escalation_recommended' fields."
            ),
            response_format=ReplyResult,
        ),
        id=f"reply_agent_{sentiment}",
    )


# ============================================================

# 5. WORKFLOW ORCHESTRATION

# ============================================================

def extract_sentiment(response: Any) -> str:
    """Parse sentiment from agent response."""
    if not isinstance(response, AgentExecutorResponse):
        return "neutral"
    try:
        result = SentimentResult.model_validate_json(response.agent_run_response.text)
        return result.sentiment
    except Exception:
        return "neutral"

# Conditional routing functions

def is_positive(response: Any) -> bool:
    return extract_sentiment(response) == "positive"

def is_negative(response: Any) -> bool:
    return extract_sentiment(response) == "negative"

def is_neutral(response: Any) -> bool:
    return extract_sentiment(response) == "neutral"

# Build the workflow with sentiment-based routing

# sentiment_agent → [positive/negative/neutral] → appropriate reply_agent → display

# Create tone-specific agents

reply_positive = make_reply_agent("positive")
reply_negative = make_reply_agent("negative")
reply_neutral = make_reply_agent("neutral")

# Display executor

@executor(id="display")
async def display_result(response: Any, ctx: WorkflowContext) -> None:
    """Final output handler."""
    print("\n" + "="*50)
    print("🗨️  FINAL SUPPORT RESPONSE")
    print("="*50)
    
    if isinstance(response, AgentExecutorResponse):
        try:
            result = ReplyResult.model_validate_json(response.agent_run_response.text)
            print(f"\nReply: {result.reply}")
            print(f"Escalation recommended: {result.escalation_recommended}")
        except Exception as e:
            print(f"Raw response: {response.agent_run_response.text}")
            print(f"Parse error: {e}")
    else:
        print(f"Unexpected response type: {type(response)}")
    
    print("="*50)
    await ctx.yield_output(response)

# Assemble workflow

workflow = (
    WorkflowBuilder()
    .set_start_executor(sentiment_agent)
    # Branch based on sentiment

    .add_edge(sentiment_agent, reply_positive, condition=is_positive)
    .add_edge(sentiment_agent, reply_negative, condition=is_negative)
    .add_edge(sentiment_agent, reply_neutral, condition=is_neutral)
    # All paths converge to display

    .add_edge(reply_positive, display)
    .add_edge(reply_negative, display)
    .add_edge(reply_neutral, display)
    .build()
)


# ============================================================

# 6. EXECUTION

# ============================================================

async def demo():
    """Run sentiment-aware support workflow."""
    test_messages = [
        "I love your product! It's absolutely amazing!",
        "I'm really frustrated with this delay. This is terrible service.",
        "What's the status of order ORD-12345?",
    ]
    
    for msg in test_messages:
        print(f"\n{'─'*50}")
        print(f"👤 USER: {msg}")
        print(f"{'─'*50}")
        
        request = AgentExecutorRequest(
            messages=[ChatMessage(Role.USER, text=msg)],
            should_respond=True,
        )
        
        await workflow.run(request)

if __name__ == "__main__":
    asyncio.run(demo())

Save this file as 16-sentiment-analysis/code_samples/sentiment_demo.py and run with python sentiment_demo.py to verify your extension works correctly.


Integrating Memory Providers for Stateful Agents

Memory providers extend agent functionality by enabling persistence across conversation turns. The Microsoft Agent Framework supports multiple memory backends through a factory pattern.

Memory Configuration

from agent_framework.memory import ChatMessageStore, Mem0Provider

# In-memory store (default, per-session)

session_memory = ChatMessageStore(max_messages=50)

# Mem0 integration for user-specific long-term memory

long_term_memory = Mem0Provider(
    api_key=os.getenv("MEM0_API_KEY"),
    user_id="user_12345",
)

# Create agent with memory

stateful_agent = AgentExecutor(
    chat_client.create_agent(
        instructions="You remember details from previous conversations.",
        tools=[lookup_order],
    ),
    id="stateful_agent",
    chat_message_store_factory=lambda: session_memory,
    context_providers=[long_term_memory],
)

Custom memory providers can be implemented by subclassing ChatMessageStore and implementing get_messages(), add_message(), and clear() methods.


Adding Observability with OpenTelemetry

Production extensions should include observability for debugging and monitoring. The Microsoft Agent Framework integrates with OpenTelemetry for distributed tracing.

Observability Implementation

from opentelemetry import trace, metrics
from agent_framework.observability import register_tracer

# Initialize tracer (from hotel_booking_workflow_sample.py pattern)

tracer = trace.get_tracer("agent_framework")
meter = metrics.get_meter("agent_framework")

# Custom spans

@executor(id="observed_agent")
async def observed_execution(ctx: WorkflowContext) -> None:
    with tracer.start_as_current_span("agent_execution") as span:
        span.set_attribute("agent.id", ctx.executor_id)
        span.set_attribute("message.count", len(ctx.messages))
        
        await ctx.next()
        
        span.set_attribute("response.length", len(ctx.result.text))

# Custom metrics

request_counter = meter.create_counter(
    "agent_requests_total",
    description="Total agent requests"
)

These patterns from hotel_booking_workflow_sample.py (lines 80-90) enable integration with Azure Monitor, Jaeger, or any OpenTelemetry-compatible backend.


Key Files for Extension Reference

File Path Purpose
AGENTS.md /AGENTS.md Project overview and contribution guidelines
requirements.txt /requirements.txt Python dependencies including agent-framework
hotel_booking_workflow_sample.py 14-microsoft-agent-framework/code-samples/hotel_booking_workflow_sample.py Complete reference for workflows, tools, middleware, and memory
README.md (Lesson 14) 14-microsoft-agent-framework/README.md MAF concepts documentation
00-course-setup/README.md 00-course-setup/README.md Environment setup and Azure authentication
co-op-translator.yml .github/workflows/co-op-translator.yml Automatic translation pipeline
devcontainer.json .devcontainer/devcontainer.json Pre-configured development environment

Best Practices for Repository Extensions

Follow these practices to ensure your extensions align with the repository's educational mission and technical standards.

Structural Guidelines

  • Maintain pedagogical flow: Start with concept explanation, show minimal code, then expand to complete implementation
  • Reuse existing patterns: Copy scaffolding from hotel_booking_workflow_sample.py rather than inventing new patterns
  • Document tool schemas: Include input/output specifications for every @ai_function

Code Quality

  • Add verification checks: Include assert statements or simple test cases
  • Use environment variables: Never commit secrets; follow .env.example patterns
  • Handle errors gracefully: Wrap external API calls in try/except blocks

Repository Integration

  • Update translation pipeline: New lessons are automatically translated via co-op-translator.yml
  • Test in DevContainer: Use .devcontainer/devcontainer.json for consistent environment
  • Reference source files: Link to specific line ranges in GitHub when documenting

Summary

Extending the functionality of AI Agents for Beginners involves working with four core extension mechanisms:

  • Custom tools: Add @ai_function decorated functions to expose new capabilities to LLMs
  • Middleware: Implement @executor hooks for cross-cutting concerns like logging and authentication
  • Memory providers: Configure ChatMessageStore or custom providers for persistent state
  • Workflows: Use WorkflowBuilder to orchestrate multi-agent systems with conditional routing

The reference implementation in 14-microsoft-agent-framework/code-samples/hotel_booking_workflow_sample.py demonstrates all these patterns in production-ready code. New lessons should follow the established folder structure and pedagogical approach while leveraging the complete Microsoft Agent Framework API.


Frequently Asked Questions

How do I add a new tool to an existing agent?

Define a function with the @ai_function decorator, then include it in the tools list when calling chat_client.create_agent(). The LLM will automatically discover the tool's schema and can invoke it during conversation. See the analyze_sentiment example in Step 2 above.

Can I use a different LLM provider instead of OpenAI?

Yes. The Microsoft Agent Framework provides chat clients for multiple providers. Replace OpenAIChatClient with AzureOpenAIChatClient, AnthropicChatClient, or implement the ChatClient protocol for custom providers. All agent code remains identical regardless of the underlying LLM.

What's the difference between memory and context providers?

Memory (chat_message_store_factory) stores conversation history for the current session, enabling multi-turn context. Context providers (context_providers) inject additional information from external sources—such as user profiles, long-term memory services like Mem0, or enterprise databases—into each agent invocation.

How do I test my extension without deploying to Azure?

Use the .devcontainer/devcontainer.json configuration for a consistent local environment. Set OpenAIChatClient with an OpenAI API key in .env, or use framework mocking utilities to test agent logic without live LLM calls. The sentiment_demo.py script runs entirely locally with no Azure dependencies.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →