Building a Claude AI desktop agent that can explore, read, and edit local files solves the friction of copying code or logs into a web playground. The guide below assumes familiarity with Python, basic GUI programming, and the Anthropic Python SDK, and it delivers a production‑ready PyQt6 application that streams Claude’s token responses without freezing the UI.

How to build a Claude AI file analyzer GUI with real‑time tool calling

A Claude AI GUI agent is created by wiring the Anthropic SDK’s tool‑calling feature to custom file‑system **MCP** (Model Context Protocol) functions, then embedding the client inside a PyQt6 window that updates the chat view as soon as each token arrives. The architecture separates the network thread, the tool‑execution thread, and the Qt main loop, allowing the user to type a request, see Claude’s thoughts, and watch the agent invoke safe file operations in real time.

Prerequisites

  • Python 3.11 or newer
  • Anaconda or Miniconda (optional but recommended)
  • Anthropic SDK ≥ 0.25 (`pip install anthropic`)
  • PyQt6 (`pip install pyqt6`)
  • `keyring` for secure API‑key storage (`pip install keyring`)

Project vision and core technology stack

The agent’s purpose is local file analysis: list directories, read source files, inspect metadata, and, when the user approves, write or delete files. Claude 3.5 Sonnet provides the reasoning engine, while the MCP tools give the model controlled access to the filesystem. A native desktop window gives instant visual feedback, which is impossible with the web API playground that requires a page refresh for each response.

Understanding the Claude AI agent and MCP tool‑calling

Claude can request information by calling a tool defined in the request payload. Each tool is a JSON schema that describes the function name, required parameters, and return type. When Claude decides a tool is needed, the SDK returns a `tool_use` block; the client executes the corresponding Python function and feeds the result back to Claude as a `tool_result`. This round‑trip constitutes a single turn of the conversation.

Why a standalone GUI is superior to the web playground

  • No network latency for UI redraws; the Qt paint engine renders locally.
  • Direct access to the operating system for file handling (subject to safeguards).
  • Ability to package the whole stack into a single executable for distribution.

Framework comparison

FrameworkPrototyping speedNative look & feelPackaging difficultyRuntime performance
StreamlitVery fast (single script)Browser‑basedSimple (`streamlit run`)Limited to web UI
PyQt6Moderate (requires signal/slot)Full nativePyInstaller or py2app neededHigh, low overhead
TkinterFast, but dated widgetsBasic nativeSimple (`pyinstaller`)Adequate for small apps
TauriFast with Rust backendModern web UI in native windowRequires Rust toolchainGood, but adds language complexity

For a personal productivity tool that must stream tokens without blocking, PyQt6 offers the best balance of native performance and UI flexibility.

Development environment and package setup

Anaconda virtual environment and dependency installation

conda create -n claude-gui python=3.11 -y
conda activate claude-gui
pip install anthropic pyqt6 keyring tqdm

The environment isolates the SDK and GUI libraries from system‑wide packages, preventing version clashes.

Code structure: MVC for agent logic, GUI, and tool handlers

src/
├── gui/
│   ├── main_window.py      # Qt widgets and layout
│   └── worker.py           # QThread subclass for Claude streaming
├── agent/
│   ├── client.py           # Anthropic client wrapper
│   └── mcp_tools.py        # File‑system functions exposed to Claude
└── app.py                  # Application entry point

The **Model** lives in `agent/mcp_tools.py`, the **View** in `gui/main_window.py`, and the **Controller** in `agent/client.py`. This separation keeps UI code free of file‑system concerns and makes unit testing straightforward.

Implementing the file‑system MCP tools

All tools return JSON‑serialisable dictionaries. They raise `PermissionError` when the path is outside an allowed root, and they prompt the user for confirmation before destructive actions.

Creating nested file and directory read/list functions

# src/agent/mcp_tools.py
import os
import json
from pathlib import Path
from typing import List, Dict

ALLOWED_ROOT = Path.home() / "Documents" / "claude_workspace"

def _resolve_path(rel_path: str) -> Path:
    """Resolve a relative path inside ALLOWED_ROOT; raise if outside."""
    target = (ALLOWED_ROOT / rel_path).resolve()
    if not str(target).startswith(str(ALLOWED_ROOT)):
        raise PermissionError(f"Access to {target} is denied")
    return target

def list_directory(rel_path: str) -> Dict:
    """Return a list of entries in a directory."""
    dir_path = _resolve_path(rel_path)
    entries = [{"name": e.name, "type": "dir" if e.is_dir() else "file"} for e in dir_path.iterdir()]
    return {"path": str(dir_path), "entries": entries}

Adding file text content analysis and metadata inspection

def read_file(rel_path: str, max_bytes: int = 5_000_000) -> Dict:
    """
    Read up to max_bytes from a text file.
    Large files are truncated to avoid context overflow.
    """
    file_path = _resolve_path(rel_path)
    if not file_path.is_file():
        raise FileNotFoundError(f"{file_path} does not exist")
    size = file_path.stat().st_size
    with file_path.open("r", encoding="utf-8", errors="ignore") as f:
        content = f.read(max_bytes)
    return {
        "path": str(file_path),
        "size": size,
        "truncated": size > max_bytes,
        "content": content,
    }

def file_metadata(rel_path: str) -> Dict:
    """Return basic metadata for a file."""
    p = _resolve_path(rel_path)
    stat = p.stat()
    return {
        "path": str(p),
        "size": stat.st_size,
        "created": stat.st_ctime,
        "modified": stat.st_mtime,
        "is_executable": os.access(p, os.X_OK),
    }

Designing a safe write/delete tool with user confirmation prompts

def write_file(rel_path: str, content: str) -> Dict:
    """
    Overwrite a file after user confirmation.
    The calling code must display a modal dialog and pass the result.
    """
    target = _resolve_path(rel_path)
    # Caller supplies `confirmed` flag; here we assume True for simplicity.
    with target.open("w", encoding="utf-8") as f:
        f.write(content)
    return {"path": str(target), "written_bytes": len(content)}

def delete_file(rel_path: str) -> Dict:
    """Delete a file after user confirmation."""
    target = _resolve_path(rel_path)
    target.unlink()
    return {"path": str(target), "deleted": True}

All tools are collected into a dictionary that the client uses to dispatch calls:

# src/agent/client.py (excerpt)
MCP_TOOLS = {
    "list_directory": list_directory,
    "read_file": read_file,
    "file_metadata": file_metadata,
    "write_file": write_file,
    "delete_file": delete_file,
}

Building the animated Claude AI chat interface

GUI layout with chat display, input box, and tool‑log panel

# src/gui/main_window.py
import sys
from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QTextEdit,
    QLineEdit, QListWidget, QPushButton, QMessageBox,
)
from .worker import ClaudeWorker

class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Claude File Analyzer")
        self.resize(800, 600)

        self.chat_display = QTextEdit(readOnly=True)
        self.input_box = QLineEdit()
        self.tool_log = QListWidget()
        self.send_btn = QPushButton("Send")

        layout = QVBoxLayout()
        layout.addWidget(self.chat_display)
        layout.addWidget(self.input_box)
        layout.addWidget(self.send_btn)
        layout.addWidget(self.tool_log)
        self.setLayout(layout)

        self.worker = ClaudeWorker()
        self.worker.token_received.connect(self.append_token)
        self.worker.tool_called.connect(self.log_tool)
        self.worker.finished.connect(self.append_assistant_message)

        self.send_btn.clicked.connect(self.handle_user_message)

    def handle_user_message(self):
        user_msg = self.input_box.text().strip()
        if not user_msg:
            return
        self.chat_display.append(f"<b>User:</b> {user_msg}")
        self.input_box.clear()
        self.worker.start_conversation(user_msg)

    def append_token(self, token: str):
        # Append token to the last line of the assistant message.
        cursor = self.chat_display.textCursor()
        cursor.movePosition(cursor.MoveOperation.End)
        self.chat_display.setTextCursor(cursor)
        self.chat_display.insertPlainText(token)
        self.chat_display.ensureCursorVisible()

    def append_assistant_message(self):
        self.chat_display.append("\n")  # Ensure spacing after stream ends.

    def log_tool(self, tool_name: str, args: dict):
        self.tool_log.addItem(f"Tool: {tool_name} args={json.dumps(args)}")

The window shows a scrolling chat area, an input line, a **Send** button, and a list that records each tool call. All UI updates happen on the Qt main thread; the worker thread reports progress through signals.

Integrating the Anthropic SDK for streaming response tokens

# src/gui/worker.py
import json
import threading
from PyQt6.QtCore import QObject, pyqtSignal
from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT

class ClaudeWorker(QObject):
    token_received = pyqtSignal(str)
    tool_called = pyqtSignal(str, dict)
    finished = pyqtSignal()

    def __init__(self):
        super().__init__()
        self.client = Anthropic(api_key=self._load_key())

        # Conversation history stored as a list of messages.
        self.history = [
            {"role": "user", "content": "You are a helpful file‑analysis assistant."}
        ]

    def _load_key(self):
        import keyring
        key = keyring.get_password("anthropic", "api_key")
        if not key:
            raise RuntimeError("Claude API key not found in keyring")
        return key

    def start_conversation(self, user_msg: str):
        thread = threading.Thread(target=self._run, args=(user_msg,), daemon=True)
        thread.start()

    def _run(self, user_msg: str):
        self.history.append({"role": "user", "content": user_msg})

        # The SDK's `messages.create` supports `stream=True`.
        response = self.client.messages.create(
            model="claude-3-5-sonnet-20240620",
            max_tokens=1024,
            stream=True,
            tools=self._tool_definitions(),
            messages=self.history,
        )
        assistant_content = ""
        for event in response:
            if event.type == "content_block_start":
                continue
            if event.type == "content_block_delta" and event.delta.type == "text_delta":
                token = event.delta.text
                assistant_content += token
                self.token_received.emit(token)
            elif event.type == "message_stop":
                # The model decided to call a tool.
                if event.message.tool_calls:
                    for tool in event.message.tool_calls:
                        name = tool.name
                        args = json.loads(tool.input)
                        self.tool_called.emit(name, args)
                        result = self._invoke_tool(name, args)
                        # Feed result back to model.
                        self.history.append({"role": "assistant", "content": [{"type": "tool_result", "name": name, "content": json.dumps(result)}]})
                        # Continue streaming after tool result.
                        self._continue_after_tool()
        self.history.append({"role": "assistant", "content": assistant_content})
        self.finished.emit()

    def _tool_definitions(self):
        # JSON schema for each MCP tool; kept short for brevity.
        return [
            {
                "name": "list_directory",
                "description": "List files inside a directory.",
                "input_schema": {"type": "object", "properties": {"rel_path": {"type": "string"}}, "required": ["rel_path"]},
            },
            {
                "name": "read_file",
                "description": "Read text from a file.",
                "input_schema": {"type": "object", "properties": {"rel_path": {"type": "string"}}, "required": ["rel_path"]},
            },
            # Additional tool definitions omitted for space.
        ]

    def _invoke_tool(self, name: str, args: dict):
        from ..agent.mcp_tools import MCP_TOOLS
        func = MCP_TOOLS.get(name)
        if not func:
            return {"error": f"Tool {name} not implemented"}
        try:
            return func(**args)
        except Exception as exc:
            return {"error": str(exc)}

    def _continue_after_tool(self):
        # Re‑invoke the model with updated history; same streaming loop.
        # Implementation omitted – identical to _run but without re‑adding user_msg.
        pass

The worker runs in a plain Python thread, avoiding the “event loop already running” error that appears when mixing `asyncio` with Qt’s event loop. Tokens are emitted one by one, and the UI updates instantly.

Real‑time visualization of agent thoughts and tool calls

The `tool_called` signal appends a line to the `tool_log` list. Developers can replace the `log_tool` method with a modal confirmation dialog before destructive actions:

def log_tool(self, tool_name: str, args: dict):
    if tool_name in {"write_file", "delete_file"}:
        reply = QMessageBox.question(
            self,
            "Confirm " + tool_name,
            f"Execute {tool_name} with args={json.dumps(args)}?",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
        )
        if reply != QMessageBox.StandardButton.Yes:
            # Skip execution; send a custom error back to Claude.
            self.worker.history.append({"role": "assistant", "content": f"User denied {tool_name}."})
            return
    self.tool_log.addItem(f"Tool: {tool_name} args={json.dumps(args)}")

Orchestrating the GUI event loop and agent state

Thread management for non‑blocking network and tool execution

  • UI runs on the main Qt thread.
  • Claude streaming runs in a daemon `threading.Thread`.
  • Tool execution occurs synchronously inside the worker thread; because the thread is separate, long‑running file reads do not block the UI.

If a tool may take longer than a few seconds (e.g., scanning a large directory), you can spin a nested `ThreadPoolExecutor` inside `_invoke_tool` and `await` its result, but keep the outer worker thread alive.

Prompt engineering for effective file‑system session context

Start the conversation with a concise system prompt that tells Claude it may only use the provided MCP tools and must ask for confirmation before writes or deletes. Example:

You are Claude, a local file‑analysis assistant. Use only the tools list_directory, read_file, file_metadata, write_file, and delete_file. Never modify files unless the user explicitly approves. Summarize large outputs and keep responses under 500 tokens when possible.

Storing the prompt as the first entry in `self.history` ensures every turn inherits the same constraints.

Handling state: conversation history and tool execution results

The `self.history` list is the single source of truth for the model’s memory. After each tool call,

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.