Back to Home
Productivity Agents

Building Personal AI Assistants: From Chatbot to Autonomous Agent

Alex Chen

AI engineer and open-source contributor. Writes about agent architectures and LLM tooling.

April 24, 202615 min read

Most "AI assistants" are glorified chatbots with a calendar plugin bolted on. They can tell you about your schedule but can't reschedule a meeting, negotiate a time, or draft the follow-up email. This...

Beyond Chat: Building a Personal AI Assistant That Actually Does Things

Most "AI assistants" are glorified chatbots with a calendar plugin bolted on. They can tell you about your schedule but can't reschedule a meeting, negotiate a time, or draft the follow-up email. This guide is about building something different — an autonomous agent that manages your calendar, triages your email, conducts research, and executes multi-step tasks while respecting the boundaries you set.

I've built three iterations of this kind of system over the past 18 months. The first was a mess of hardcoded LangChain chains. The second used a custom orchestration layer. The third, which I'll draw from here, is built on a more principled architecture that actually scales to real daily use.

Architecture: The Three-Layer Model

A personal assistant that does real work needs three distinct layers, not one monolithic LLM call.

┌─────────────────────────────────────────────────┐
│                  User Interface                  │
│         (CLI, Slack, Telegram, Voice)            │
├─────────────────────────────────────────────────┤
│              Orchestration Layer                  │
│    (Planning, Memory, State, Tool Routing)        │
├─────────────────────────────────────────────────┤
│              Tool / Action Layer                  │
│  (Calendar API, Email, Web, Filesystem, Code)     │
└─────────────────────────────────────────────────┘

The Orchestration Layer Is Where the Hard Problems Live

The orchestration layer isn't just "call the LLM and see what tool it picks." It needs to:

  • Decompose complex requests into ordered steps
  • Maintain conversation state across multi-turn interactions
  • Handle tool failures gracefully (APIs go down, auth tokens expire)
  • Enforce permission boundaries before executing actions
  • Manage memory — both short-term (this conversation) and long-term (your preferences, recurring patterns)

Here's the core orchestration loop, simplified:

import asyncio
from dataclasses import dataclass, field
from typing import Any, Callable

@dataclass
class AssistantState:
    messages: list[dict] = field(default_factory=list)
    active_plan: list[dict] = field(default_factory=list)
    completed_steps: list[dict] = field(default_factory=list)
    context: dict = field(default_factory=dict)  # persistent context

class PersonalAssistant:
    def __init__(self, llm, tools: dict[str, Callable], memory_store):
        self.llm = llm
        self.tools = tools
        self.memory = memory_store
        self.state = AssistantState()
    
    async def handle_request(self, user_input: str) -> str:
        # 1. Load relevant long-term memory
        relevant_context = self.memory.retrieve(user_input, k=5)
        
        # 2. Build prompt with context, tools, and current state
        system_prompt = self._build_system_prompt(relevant_context)
        
        # 3. Plan — get structured output from LLM
        plan = await self._plan(system_prompt, user_input)
        
        # 4. Execute plan step by step
        results = []
        for step in plan:
            result = await self._execute_step(step)
            results.append(result)
            
            # Abort if a critical step fails
            if result.get("status") == "error" and step.get("critical"):
                results.append({"abort": True, "reason": result["error"]})
                break
        
        # 5. Synthesize final response
        response = await self._synthesize(user_input, results)
        
        # 6. Store interaction in memory
        self.memory.store(user_input, response, results)
        
        return response

The _plan method is where you get structured output from the LLM — a list of tool calls with parameters. I use Pydantic models for this, not free-form JSON:

from pydantic import BaseModel

class ToolCall(BaseModel):
    tool: str
    parameters: dict[str, Any]
    rationale: str
    critical: bool = False  # should we abort if this fails?
    requires_confirmation: bool = False  # human-in-the-loop?

class Plan(BaseModel):
    reasoning: str
    steps: list[ToolCall]
    estimated_complexity: str  # "simple", "moderate", "complex"

This structured approach matters because free-form JSON from LLMs is unreliable at the edges. Pydantic validation catches malformed tool calls before they reach your API clients.

Tool Integration: The Four Core Capabilities

1. Calendar Management

Calendar integration is where most personal assistants fall apart. Reading events is trivial. The hard part is modifying calendars — rescheduling, finding mutual availability, handling recurring events, dealing with timezone chaos.

The Google Calendar API remains the most capable option. Microsoft Graph is fine if you're in the Microsoft ecosystem, but the API surface is more complex for the same functionality.

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from datetime import datetime, timedelta

class CalendarTool:
    def __init__(self, credentials: Credentials):
        self.service = build('calendar', 'v3', credentials=credentials)
    
    def list_events(self, days_ahead: int = 7, calendar_id: str = 'primary'):
        now = datetime.utcnow().isoformat() + 'Z'
        end = (datetime.utcnow() + timedelta(days=days_ahead)).isoformat() + 'Z'
        
        result = self.service.events().list(
            calendarId=calendar_id,
            timeMin=now,
            timeMax=end,
            singleEvents=True,
            orderBy='startTime'
        ).execute()
        
        return result.get('items', [])
    
    def find_free_slots(self, duration_minutes: int, days_ahead: int = 5):
        """Find available time slots — this is the non-trivial part."""
        events = self.list_events(days_ahead)
        free_slots = []
        
        # Group events by date, find gaps between them
        # Account for working hours (9am-6pm by default)
        # Handle timezone conversions
        # ... (implementation details matter enormously here)
        
        return free_slots
    
    def create_event(self, summary: str, start: str, end: str, 
                     attendees: list[str] = None, description: str = ""):
        event = {
            'summary': summary,
            'start': {'dateTime': start, 'timeZone': 'America/New_York'},
            'end': {'dateTime': end, 'timeZone': 'America/New_York'},
            'description': description,
        }
        if attendees:
            event['attendees'] = [{'email': a} for a in attendees]
        
        return self.service.events().insert(
            calendarId='primary', body=event, sendNotifications=True
        ).execute()

The real challenge nobody talks about: free-slot finding. Naive implementations break when you have back-to-back meetings, all-day events, events in different timezones, and recurring events with exceptions. I spent more time on find_free_slots than on the rest of the calendar tool combined.

Here's what a robust implementation needs to handle:

  • Merge overlapping events before computing gaps
  • Respect buffer time between meetings (I use 15 minutes)
  • Filter out weekends and outside-of-work-hours slots unless explicitly requested
  • Handle all-day events correctly (they block the entire day for availability purposes)
  • Convert everything to UTC internally, display in the user's local timezone

2. Email Handling

Email is where an AI assistant can provide the most daily value, but it's also where the most can go wrong. Sending an email on your behalf is an irreversible action with real consequences.

Architecture for email integration:

import base64
from email.mime.text import MIMEText

class EmailTool:
    def __init__(self, credentials):
        self.service = build('gmail', 'v1', credentials=credentials)
    
    def search(self, query: str, max_results: int = 10):
        """Search using Gmail's query syntax."""
        results = self.service.users().messages().list(
            userId='me', q=query, maxResults=max_results
        ).execute()
        
        messages = []
        for msg_ref in results.get('messages', []):
            msg = self.service.users().messages().get(
                userId='me', id=msg_ref['id'], format='full'
            ).execute()
            messages.append(self._parse_message(msg))
        
        return messages
    
    def draft_email(self, to: str, subject: str, body: str) -> dict:
        """Create a draft — DON'T send directly."""
        message = MIMEText(body)
        message['to'] = to
        message['subject'] = subject
        raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
        
        draft = self.service.users().drafts().create(
            userId='me', body={'message': {'raw': raw}}
        ).execute()
        
        return draft  # User reviews before sending
    
    def send_email(self, draft_id: str) -> dict:
        """Actually send — requires explicit user confirmation."""
        return self.service.users().drafts().send(
            userId='me', body={'id': draft_id}
        ).execute()

Critical design decision: The assistant should draft emails, never send them autonomously. I learned this the hard way after my assistant sent a slightly wrong meeting time to a client. The workflow should be:

  1. User: "Email Sarah about pushing Thursday's meeting to 3pm"
  2. Assistant: Creates draft with appropriate tone, correct details
  3. Assistant: "Draft created. Here's what I wrote: [preview]. Want me to send it, edit it, or cancel?"

This human-in-the-loop pattern applies to any irreversible external action.

Email triage is where the assistant really shines. Set up a periodic task that:

async def triage_inbox(self):
    """Run every 30 minutes. Classify and summarize new emails."""
    new_emails = self.email.search("is:unread -category:promotions")
    
    for email in new_emails:
        classification = await self.llm.classify(
            email,
            categories=["urgent", "needs_reply", "fyi", "can_wait", "spam"]
        )
        
        if classification == "urgent":
            await self.notify_user(
                f"🔴 Urgent email from {email['from']}: {email['subject']}\n"
                f"Summary: {summarize(email['body'])}"
            )
        elif classification == "needs_reply":
            draft = await self.draft_reply(email)
            await self.notify_user(
                f"📧 Needs reply from {email['from']}: {email['subject']}\n"
                f"I've drafted a response: {draft['preview']}"
            )

3. Research Capability

Research is where you need to think carefully about tool design. A research tool shouldn't just "search the web" — it should execute a research workflow.

class ResearchTool:
    def __init__(self, search_client, browser_client, llm):
        self.search = search_client  # Tavily, Serper, or Brave Search API
        self.browser = browser_client  # Playwright or similar
        self.llm = llm
    
    async def research(self, query: str, depth: str = "standard") -> dict:
        """
        Multi-step research:
        1. Generate sub-questions
        2. Search for each
        3. Read and extract from top results
        4. Synthesize findings
        """
        # Step 1: Decompose the query
        sub_questions = await self.llm.generate(
            f"Break this research question into 3-5 specific sub-questions: {query}"
        )
        
        # Step 2: Search for each sub-question
        all_sources = []
        for sq in sub_questions:
            results = self.search.search(sq, max_results=3)
            all_sources.extend(results)
        
        # Step 3: Deduplicate and rank sources
        ranked_sources = self._rank_sources(all_sources, query)
        
        # Step 4: Read top sources (this is expensive — limit it)
        max_sources = 5 if depth == "standard" else 10
        extracted_content = []
        
        for source in ranked_sources[:max_sources]:
            try:
                content = await self.browser.get_text(source['url'])
                extracted_content.append({
                    'url': source['url'],
                    'content': self._extract_relevant(content, query),
                    'title': source['title']
                })
            except Exception:
                continue  # Graceful failure — some sites block scrapers
        
        # Step 5: Synthesize
        synthesis = await self.llm.generate(
            f"Synthesize these findings about '{query}':\n\n"
            + "\n\n---\n\n".join(
                f"Source: {s['title']} ({s['url']})\n{s['content']}" 
                for s in extracted_content
            )
        )
        
        return {
            'query': query,
            'synthesis': synthesis,
            'sources': extracted_content,
            'sub_questions': sub_questions
        }

Tools I've actually used and recommend:

Tool Purpose Cost Notes
Tavily Search API Web search optimized for AI agents Free tier: 1000 req/month Returns cleaned content, not just snippets. Best option for agents.
Brave Search API Web search Free tier: 2000 req/month Good results, but you still need to scrape pages yourself
Playwright Browser automation for page reading Free (self-hosted) Handles JS-rendered pages. Use headless mode.
Jina Reader URL to clean text Free tier available r.jina.ai/URL returns clean markdown. Saves you from running Playwright.

4. Task Execution

Task execution means running code, managing files, and interacting with local systems. This is where you need to be most careful about security.

import subprocess
import tempfile
import os

class TaskExecutionTool:
    """Execute tasks in a sandboxed environment."""
    
    def __init__(self, workspace_dir: str, allow_network: bool = False):
        self.workspace = workspace_dir
        self.allow_network = allow_network
    
    def run_code(self, language: str, code: str, timeout: int = 30) -> dict:
        """Execute code in a sandboxed subprocess."""
        with tempfile.NamedTemporaryFile(
            suffix=self._get_extension(language),
            dir=self.workspace,
            mode='w',
            delete=False
        ) as f:
            f.write(code)
            f.flush()
            
            try:
                result = subprocess.run(
                    self._get_command(language, f.name),
                    capture_output=True,
                    text=True,
                    timeout=timeout,
                    cwd=self.workspace,
                    env=self._sandboxed_env()
                )
                return {
                    "stdout": result.stdout,
                    "stderr": result.stderr,
                    "returncode": result.returncode
                }
            except subprocess.TimeoutExpired:
                return {"error": "Execution timed out", "timeout": timeout}
            finally:
                os.unlink(f.name)
    
    def _sandboxed_env(self) -> dict:
        """Minimal environment — no access to user secrets."""
        env = {"PATH": "/usr/bin:/bin", "HOME": self.workspace}
        if not self.allow_network:
            # In production, use proper network namespace isolation
            pass
        return env
    
    def manage_file(self, action: str, path: str, content: str = None) -> dict:
        """File operations within the workspace only."""
        full_path = os.path.normpath(os.path.join(self.workspace, path))
        
        # CRITICAL: Prevent path traversal
        if not full_path.startswith(self.workspace):
            return {"error": "Path traversal detected", "path": path}
        
        if action == "read":
            with open(full_path, 'r') as f:
                return {"content": f.read()}
        elif action == "write":
            os.makedirs(os.path.dirname(full_path), exist_ok=True)
            with open(full_path, 'w') as f:
                f.write(content)
            return {"status": "written", "path": full_path}
        elif action == "list":
            return {"files": os.listdir(full_path)}

Memory: The Difference Between a Tool and an Assistant

Without memory, your assistant is a stateless function. With it, it learns your preferences, remembers context, and gets more useful over time.

from datetime import datetime
import chromadb
from chromadb.utils import embedding_functions

class AssistantMemory:
    def __init__(self, persist_dir: str = "./memory"):
        self.client = chromadb.PersistentClient(path=persist_dir)
        self.ef = embedding_functions.OpenAIEmbeddingFunction(
            model_name="text-embedding-3-small"
        )
        
        # Short-term: conversation history
        self.conversations = self.client.get_or_create_collection(
            "conversations", embedding_function=self.ef
        )
        
        # Long-term: facts, preferences, patterns
        self.knowledge = self.client.get_or_create_collection(
            "knowledge", embedding_function=self.ef
        )
    
    def store(self, user_input: str, response: str, tool_results: list):
        """Store conversation turn with metadata."""
        self.conversations.add(
            documents=[f"User: {user_input}\nAssistant: {response}"],
            metadatas=[{"timestamp": datetime.now().isoformat(), "type": "conversation"}],
            ids=[f"conv_{datetime.now().timestamp()}"]
        )
        
        # Extract and store facts/preferences
        # "I prefer meetings in the morning" → store as knowledge
        facts = self._extract_facts(user_input, response)
        for fact in facts:
            self.knowledge.add(
                documents=[fact],
                metadatas=[{"timestamp": datetime.now().isoformat(), "type": "preference"}],
                ids=[f"fact_{hash(fact)}"]
            )
    
    def retrieve(self, query: str, k: int = 5) -> list[str]:
        """Retrieve relevant context for current query."""
        conv_results = self.conversations.query(query_texts=[query], n_results=k)
        knowledge_results = self.knowledge.query(query_texts=[query], n_results=k)
        
        # Merge and deduplicate
        context = []
        for doc in (conv_results.get("documents", [[]])[0] + 
                    knowledge_results.get("documents", [[]])[0]):
            if doc not in context:
                context.append(doc)
        
        return context[:k]

Two types of memory matter:

  1. Episodic memory — what happened in past conversations. "You asked about this topic last Tuesday, here's what we found."
  2. Semantic memory — extracted facts and preferences. "You prefer morning meetings." "Your timezone is EST." "You always CC your manager on client emails."

ChromaDB works well for this because it runs locally, persists to disk, and handles the embedding + retrieval pipeline without external services. For production, consider Qdrant if you need better filtering and scaling.

Privacy: The Non-Negotiable Foundation

This assistant reads your emails, manages your calendar, and has access to your files. A privacy breach isn't an inconvenience — it's a catastrophe.

Principle 1: Local-First Architecture

┌──────────────────────────────────────────────┐
│              Your Machine                      │
│  ┌──────────┐  ┌──────────┐  ┌────────────┐  │
│  │ Assistant │  │ Memory   │  │ File Access │  │
│  │   Core    │  │ (local)  │  │ (sandboxed)│  │
│  └────┬─────┘  └──────────┘  └────────────┘  │
│       │                                        │
│  ┌────▼──────────────────────────────────┐    │
│  │     LLM API Calls (encrypted)         │    │
│  │  Only send: task + minimal context    │    │
│  │  Never send: raw emails, full calendar│    │
│  └───────────────────────────────────────┘    │
└──────────────────────────────────────────────┘

What stays local:

  • All memory and conversation history
  • Email content and calendar details
  • File contents
  • API credentials and tokens

What goes to the LLM:

  • The current task description
  • Minimal relevant context (not your entire email history)
  • Tool outputs needed for the current step

Principle 2: Data Minimization in Prompts

Don't dump your entire inbox into the LLM context. Instead:

def build_email_context(self, email: dict) -> str:
    """Send minimal information to the LLM."""
    return f"""
    From: {email['from']}
    Subject: {email['subject']}
    Date: {email['date']}
    Body (first 500 chars): {email['body'][:500]}
    """
    # NOT the full email with all headers, HTML, tracking pixels, etc.

Principle 3: Credential Isolation

import keyring

class CredentialManager:
    """Store credentials in the OS keychain, never in config files."""
    
    @staticmethod
    def store(service: str, key: str, value: str):
        keyring.set_password(f"ai_assistant_{service}", key, value)
    
    @staticmethod
    def retrieve(service: str, key: str) -> str:
        return keyring.get_password(f"ai_assistant_{service}", key)
    
    @staticmethod
    def get_google_credentials():
        """OAuth tokens stored in keyring, not on disk."""
        token_json = CredentialManager.retrieve("google", "oauth_token")
        return Credentials.from_authorized_user_info(json.loads(token_json))

Principle 4: Audit Logging

Log every action the assistant takes. Every email drafted, every calendar event created, every file accessed. This isn't just for security — it's for debugging when the assistant does something unexpected.

import logging

audit_logger = logging.getLogger("assistant.audit")
audit_handler = logging.FileHandler("~/.assistant/audit.log")
audit_logger.addHandler(audit_handler)

def log_action(action_type: str, details: dict, result: str):
    audit_logger.info(json.dumps({
        "timestamp": datetime.now().isoformat(),
        "action": action_type,
        "details": details,
        "result": result
    }))

Putting It Together: The Configuration

Here's how I configure the whole system:

# ~/.assistant/config.yaml
llm:
  primary: "gpt-4o"          # for complex reasoning
  fast: "gpt-4o-mini"        # for classification, extraction
  local: "llama-3.1-8b"     # for privacy-sensitive tasks (optional)

tools:
  calendar:
    provider: "google"
    default_reminder_minutes: 15
    buffer_between_meetings: 15
    working_hours: { start: "09:00", end: "18:00", timezone: "America/New_York" }
  
  email:
    provider: "gmail"
    auto_draft: true          # always draft, never auto-send
    triage_interval_minutes: 30
    categories: ["urgent", "needs_reply", "fyi", "can_wait"]
  
  research:
    search_provider: "tavily"
    max_sources: 5
    use_browser: true
  
  tasks:
    workspace: "~/.assistant/workspace"
    allow_network: false
    max_execution_time: 30

privacy:
  store_locally: true
  log_all_actions: true
  minimize_llm_context: true
  require_confirmation: ["send_email", "delete_event", "run_code"]

What I've Learned (The Honest Part)

What works well:

  • Email triage saves me 30+ minutes daily
  • Calendar management with natural language ("move my 2pm to Thursday morning") is genuinely useful
  • Research tasks that would take 20 minutes of tab-switching now take 2 minutes of review

What doesn't work well yet:

  • Complex multi-party scheduling ("find a time that works for me, Sarah, and the London team") is still flaky. The free-slot intersection logic breaks with enough constraints.
  • Tone matching for emails is inconsistent. I still rewrite 40% of drafts.
  • Long-running research tasks sometimes hallucinate sources. Always verify URLs.
  • The LLM occasionally misunderstands ambiguous requests and takes irreversible actions. The confirmation-gate pattern is essential, not optional.

What surprised me:

  • The memory system matters more than any individual tool. After three months of use, the assistant knows my patterns well enough to proactively suggest things.
  • Running a local model (via Ollama) for privacy-sensitive tasks is viable but noticeably slower and less capable. I use it for email classification but not for drafting.
  • The hardest engineering problem isn't any individual tool — it's the orchestration layer deciding which tools to use, in what order, and how to handle failures.

Getting Started

If you're building this from scratch, start with one tool and get it right before adding more. My recommended order:

  1. Calendar read-only — lowest risk, immediate value
  2. Email triage — read-only classification and summarization
  3. Calendar write — now you need confirmation gates
  4. Email drafting — the highest-value feature
  5. Research — useful but not daily-essential
  6. Task execution — last, because it's the highest risk

Use LangGraph if you want a framework that handles state management and tool routing out of the box. Use a custom orchestration layer (like the one shown above) if you want full control and don't want to fight framework abstractions when they don't fit your use case.

The assistant I use daily is about 2,000 lines of Python, uses GPT-4o for orchestration, ChromaDB for memory, and runs on a Mac Mini in my apartment. It's not a product. It's a personal tool that saves me an hour a day. That's the bar to aim for — not a demo, but a daily driver.

Keywords

AI agentproductivity-agents