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
| Framework | Prototyping speed | Native look & feel | Packaging difficulty | Runtime performance |
|---|---|---|---|---|
| Streamlit | Very fast (single script) | Browser‑based | Simple (`streamlit run`) | Limited to web UI |
| PyQt6 | Moderate (requires signal/slot) | Full native | PyInstaller or py2app needed | High, low overhead |
| Tkinter | Fast, but dated widgets | Basic native | Simple (`pyinstaller`) | Adequate for small apps |
| Tauri | Fast with Rust backend | Modern web UI in native window | Requires Rust toolchain | Good, 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,