Build a Local AI Agent Orb
Build a local desktop AI agent with a reactive UI and safe Python tools.
Introduction
30 Second Summary
Desktop assistants can sound confident even when they cannot check a live fact on your computer. Slow responses can also make a small app feel frozen.
In this project, you will build a local Tkinter desktop assistant whose orb shows when it is waiting, thinking, acting, or speaking-ready. Its bounded agent loop runs Qwen3 4B through Ollama with safe tools for time or arithmetic.
What You'll Build
Your always-on-top orb answers requests while its color and event log reveal exactly what the assistant is doing.
By the end of this project, you'll have:
- A local desktop assistant that answers text prompts through a model running on your own computer.
- A set of safe time and arithmetic tools that return live observations without evaluating model-generated code.
- A responsive four-state orb with an event log that displays model rounds, tool arguments, tool results, handled errors, and the stopping limit.
- Secret Mission: Interrupt Ollama and feed the calculator invalid input to prove the assistant fails safely before recovering.
Are there any prerequisites?
You need a Windows PC with Python and a browser already installed. You should be comfortable reading small Python programs.
Before We Start
This is your moment to commit to the local agent orb you are building. You will also pin down why visible states, local inference, safe tools, and a responsive interface matter before the hands-on work begins.
Prepare Windows for Local AI
Local models use meaningful disk space. Their performance also depends on your processor and graphics adapter. A machine baseline helps you catch storage or compatibility problems before a large download begins.
In this step, you'll inspect your Windows computer with Windows PowerShell. You'll set up Visual Studio Code, Ollama, the local Qwen3 4B model, and an isolated Python environment.
In this step, get ready to:
- Record your Windows version, Python version, hardware, and free disk space.
- Install the editor and local model runtime.
- Create the project environment and verify the local model.
Record your machine baseline
The baseline captures the parts of your computer that influence local inference. It also confirms that your system drive has enough room for the runtime and model.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open Windows PowerShell.
- Run the machine baseline checks with this block:
Get-ComputerInfo -Property "*version"
python --version
Get-CimInstance -ClassName Win32_Processor
Get-CimInstance -ClassName Win32_VideoController
Get-CimInstance -ClassName Win32_ComputerSystem
python -c "import shutil; print(round(shutil.disk_usage('C:\\').free / 1024**3, 2), 'GB free')"
What do these checks capture?
- The first command reports Windows version properties.
- The second command prints the active Python version.
- The three CIM queries report your processor, graphics adapter, and total physical memory.
- The final command uses shutil.disk_usage() to display free space on the system drive in gigabytes.
PowerShell now shows the six values that define your starting point. The graphics memory value can be approximate on some hardware, so keep the adapter name as the main GPU reference.
Check the baseline gates
These checks decide whether this computer is ready for the downloads in this project.
- Stop if the Python version is lower than 3.8.
- Stop if the Windows version is older than Windows 10 22H2.
- Stop if the system drive has less than 8 GB free.
- Use the documented OLLAMA_MODELS setting from the Ollama Windows guide to choose another drive before downloading a model.
- Leave this PowerShell window open so you can transfer the results into your project notes.
Install the local AI workspace
The workspace needs an editor for project files. It also needs a local runtime that can download and serve the model without a cloud account.
The installers can take a few minutes to finish. A quiet installer window during that time usually means Windows is still copying files.
- Switch back to your browser.
- Visit the official Visual Studio Code Windows setup page.
- Download the Windows User setup installer.
- Run the downloaded Visual Studio Code installer.
- Keep the default installation options.
- Return to your browser.
- Visit the official Ollama Windows page.
- Download OllamaSetup.exe.
Why use these installers?
Visual Studio Code User setup installs without requiring administrator permissions. It also adds the code command to your path after the console restarts.
The Ollama installer also works without administrator rights. Ollama provides the local runtime that serves the model to your Python application.
- Run OllamaSetup.exe.
- Complete the Ollama installation with its default location.
Good progress. Your editor and local model runtime are now installed on this computer.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
You'll see the Visual Studio Code welcome screen. This is where the project folder and its local terminal stay together.
- Click File in the top menu bar.
- Click Open Folder.
- Select your Desktop in the folder picker.
- Click New Folder.
- Enter local-agent-orb as the folder name.
- Click Select Folder to open the new folder.
The local-agent-orb folder is now open in the Explorer sidebar. Every project file you create in this step belongs inside that folder.
- Click the New File icon at the top of the Explorer sidebar.
- Name the file hardware-notes.txt.
- Switch back to the PowerShell window from the baseline check.
- Copy the baseline commands with their complete output.
- Return to hardware-notes.txt in Visual Studio Code.
- Paste the baseline commands with their output into the file.
- Save hardware-notes.txt by pressing Ctrl+S.
Your notes now preserve the Windows version, Python version, CPU, GPU, RAM, and free-space result. This record gives you evidence for later performance comparisons.
- Click the New File icon in the Explorer sidebar.
- Name the file requirements.txt.
- Add the pinned Ollama Python SDK dependency with this content:
ollama==0.6.3
Why pin the SDK version?
The exact 0.6.3 pin keeps the SDK interface consistent with the agent code you build later. Reinstalling from this file produces the same dependency version.
- Save requirements.txt by pressing Ctrl+S.
- Confirm that hardware-notes.txt appears in the Explorer sidebar.
- Confirm that requirements.txt appears in the Explorer sidebar.
Missing one of the files?
Check that both files sit directly inside local-agent-orb. A file nested inside another folder does not appear beside the project folder name.
Check that Windows has not added another .txt extension to either filename.
Help me check my project files
✔️ Awesome, I've got everything!
Your machine report and pinned dependency file are saved inside local-agent-orb.
ⓧ I'd like to double check the full code
Confirm that hardware-notes.txt contains these recorded sections:
- Windows version output from Get-ComputerInfo.
- Python version output.
- Processor output from Win32_Processor.
- Graphics output from Win32_VideoController.
- Memory output from Win32_ComputerSystem.
- System-drive free-space output from shutil.disk_usage().
Your complete requirements.txt should match this file:
ollama==0.6.3
What should match?
The filename must be requirements.txt. Its only line must pin the Ollama package to 0.6.3.
A virtual environment keeps this project's Python packages separate from packages used by other projects. Activating it ensures the pinned SDK installs into local-agent-orb.
- Click Terminal in the Visual Studio Code menu bar.
- Click New Terminal.
- Click the terminal profile dropdown in the terminal panel.
- Select Command Prompt.
- Create and activate the project environment with these commands:
python -m venv tutorial-env
tutorial-env\Scripts\activate.bat
What do these commands do?
- The first command creates an isolated Python environment in the tutorial-env folder.
- The second command activates that environment for the current Command Prompt session.
You'll see tutorial-env in the Explorer sidebar. The terminal prompt also begins with (tutorial-env) after activation.
Environment prompt unchanged?
Confirm that the terminal profile says Command Prompt. The activation command in this project targets a Windows batch terminal.
Confirm that the terminal path ends with local-agent-orb before creating the environment.
Help me activate the environment
- Install the pinned project dependency by running this command:
python -m pip install -r requirements.txt
What does this install?
The command reads requirements.txt from the open project folder. It installs ollama==0.6.3 into the active tutorial-env environment.
The installation may spend a minute downloading supporting packages. You'll see package progress in the terminal until the installation completes.
Dependency installation failed?
Check that the prompt still begins with (tutorial-env). Check that requirements.txt is saved directly inside local-agent-orb.
A download failure can also come from a temporary network interruption. Retry the command after confirming your browser can load a page.
Help me fix the SDK installation
Pull the model and verify the setup
Ollama can only serve models that exist on your computer. The qwen3:4b download is approximately 2.5 GB, while the Ollama binary needs at least 4 GB of storage.
- Download the selected local model by running this command in the activated terminal:
ollama pull qwen3:4b
What does this command do?
The command downloads the qwen3:4b model into Ollama's local model storage. Ollama can reuse that local copy for later requests.
This is the longest download in the setup. The terminal shows layer progress, so changing percentages confirm that the download is still moving.
Model download stopped?
Confirm that Ollama is running from the Windows Start menu. Confirm that your internet connection is still active before retrying the same command.
If storage is the problem, configure OLLAMA_MODELS for another drive with at least 8 GB free.
Help me finish the model download
- Return to hardware-notes.txt in the Explorer sidebar.
- Add a sentence confirming an Ollama binary storage budget of at least 4 GB.
- Add a sentence confirming that qwen3:4b is available locally at approximately 2.5 GB.
- Save hardware-notes.txt by pressing Ctrl+S.
Before you run the final checks, do you expect the interface test, SDK import, and model listing to pass on the first try? Hold your prediction in mind.
- Run the final setup checks with this block:
- Close the Tk demo window after it opens so the remaining checks can continue.
python -m tkinter
python -c "from ollama import chat; print('Ollama SDK import succeeded')"
ollama ls
What do the final checks prove?
- The first command starts the standard Tkinter demonstration window.
- The second command imports chat from the installed Ollama Python SDK.
- The final command lists the models currently stored by Ollama.
You'll see a Tk demonstration window first. After you close it, the terminal prints Ollama SDK import succeeded and lists qwen3:4b among the local models.
That's the setup confirmed. Your Windows machine can display a Python interface, import the pinned SDK, and serve the selected local model.
One of the final checks failed?
- Restart Visual Studio Code if the terminal cannot find Ollama after installation.
- Reactivate tutorial-env if the SDK import fails.
- Run the model pull command again if qwen3:4b is missing from the model list.
Help me fix the final setup check
Your local AI workspace is ready. Next, you'll turn this verified setup into an always-on-top orb with visible activity states.
Draw the Reactive Orb
Your local Ollama setup is ready. Model requests can introduce enough delay to make visual problems harder to isolate.
You'll build the Tkinter shell before connecting the model. This visible interface establishes the state machine that later requests use to show their progress.
In this step, get ready to:
- Create an always-on-top window with a colored orb.
- Add the prompt controls, answer area, and event log.
- Run the shell to test its states, controls, and movable title-bar window.
Create the window and orb
The main application class owns every visible widget. Keeping the window inside one class gives later model events a single place to update the orb, answer, and log.
- In the Visual Studio Code Explorer sidebar, right-click the local-agent-orb folder.
- Select New File.
- Enter app.py as the file name.
- Add the window foundation to app.py by pasting this code:
import tkinter as tk
STATE_COLORS = {
"waiting": "#2563eb",
"thinking": "#7c3aed",
"acting": "#f59e0b",
"speaking-ready": "#10b981",
"error": "#dc2626",
}
class AgentOrbApp:
def __init__(self, root):
self.root = root
self.root.title("Local Agent Orb")
self.root.geometry("680x650")
self.root.attributes("-topmost", True)
def main():
root = tk.Tk()
AgentOrbApp(root)
root.mainloop()
if __name__ == "__main__":
main()
What does this code do?
- STATE_COLORS gives each activity state a fixed color. Blue represents the initial waiting state.
- AgentOrbApp stores the root window that later owns every widget.
- -topmost keeps the orb above other windows while preserving the normal Windows title bar.
- main() creates the application before entering Tkinter's event loop.
- Save app.py by pressing Ctrl+S.
- Switch back to the Command Prompt terminal from the previous step.
- Launch the window by running this command:
python app.py
What does this run prove?
This command starts the root window and enters Tkinter's event loop. The process stays active until you close the window.
You'll see a blank window titled Local Agent Orb. It stays above other windows and keeps the standard controls for moving or closing it.
- Close the window with the X button in its title bar.
Window missing or closing immediately?
Confirm the terminal still shows the activated tutorial-env environment. Check that the bottom of app.py calls main().
Help me fix the first Tkinter window
The orb needs stored interface values before it can display a useful state. The answer text starts with a local instruction because no model connection exists in this step.
- In app.py, find the line containing self.root.attributes("-topmost", True).
- Add the interface values and blue orb directly below that line by pasting this code:
self.tools_enabled = tk.BooleanVar(value=True)
self.answer_text = tk.StringVar(
value="Ask a question, request the current time, or try arithmetic."
)
header = tk.Frame(root, padx=16, pady=12)
header.pack(fill="x")
self.canvas = tk.Canvas(
header,
width=180,
height=180,
bg="#111827",
highlightthickness=0,
)
self.canvas.pack()
self.orb = self.canvas.create_oval(
25,
25,
155,
155,
fill=STATE_COLORS["waiting"],
outline="#bfdbfe",
width=4,
)
How is the orb drawn?
- BooleanVar stores whether safe tools are enabled for a later step.
- StringVar stores the text shown in the answer area.
- Canvas provides the drawing surface for the orb.
- create_oval() draws the circle with the waiting color from STATE_COLORS.
- Save app.py.
- Return to the Command Prompt terminal from earlier.
- Run the updated shell with this command:
python app.py
What changed?
The application now creates a canvas inside its header frame. The blue circle proves the waiting-state color is reaching the visible interface.
You'll see a blue orb near the top of the dark canvas. The remaining space is ready for the controls and log.
- Close the window with its title-bar X button.
Don't see the blue orb?
Check that the new lines remain indented inside AgentOrbApp.__init__(). Confirm the color lookup uses the existing waiting key.
Help me find my missing Tkinter orb
Add the controls and state feedback
A reactive shell needs controls for input plus separate surfaces for the answer and internal events. These widgets make each later model round visible without changing the window structure.
- In app.py, place your cursor directly below the self.orb block.
- Add the state label, prompt field, and tools checkbox by pasting this code:
self.orb_text = self.canvas.create_text(
90,
90,
text="WAITING",
fill="white",
font=("Segoe UI", 14, "bold"),
)
controls = tk.Frame(root, padx=16, pady=8)
controls.pack(fill="x")
self.prompt_entry = tk.Entry(controls, font=("Segoe UI", 11))
self.prompt_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
tk.Checkbutton(
root,
text="Enable safe tools",
variable=self.tools_enabled,
).pack(anchor="w", padx=16)
What do these controls prepare?
- orb_text prints the current state in the center of the circle.
- prompt_entry holds the learner's text request.
- Checkbutton connects the visible tools option to tools_enabled.
- Save app.py.
- Switch back to the Command Prompt terminal from earlier.
- Preview the new controls by running this command:
python app.py
What does this preview check?
This run checks the geometry of the label, input field, and checkbox. Each widget is managed by pack() so it becomes visible in the window.
You'll see WAITING inside the orb. A prompt field and checked tools option sit below it.
- Close the preview with the title-bar X button.
- In app.py, place your cursor directly below the tools checkbox block.
- Add the answer area and event log by pasting this code:
answer_frame = tk.LabelFrame(root, text="Answer", padx=10, pady=10)
answer_frame.pack(fill="x", padx=16, pady=(8, 6))
tk.Label(
answer_frame,
textvariable=self.answer_text,
justify="left",
anchor="w",
wraplength=620,
).pack(fill="x")
log_frame = tk.LabelFrame(root, text="Agent Event Log", padx=8, pady=8)
log_frame.pack(fill="both", expand=True, padx=16, pady=(6, 16))
self.log_text = tk.Text(
log_frame,
height=14,
wrap="word",
font=("Consolas", 9),
)
self.log_text.pack(fill="both", expand=True)
Why separate answers from events?
The answer area shows the assistant's learner-facing response. The event log exposes state changes and request details for debugging.
Keeping these surfaces separate makes the agent's internal workflow observable without crowding its final answer.
- Save app.py.
- Return to the Command Prompt terminal from earlier.
- Preview the complete widget layout by running this command:
python app.py
What should the layout contain?
The window now reserves one area for the assistant's answer and a larger area for event history. The log starts empty because no request has been submitted.
You'll see the answer instruction below the checkbox. The empty Agent Event Log fills the lower portion of the window.
- Close the preview with the title-bar X button.
Widgets overlapping or missing?
Check that each widget block remains inside __init__() with the same indentation. Confirm every visible frame has a matching pack() call.
Help me fix the Local Agent Orb layout
One state method keeps the circle color and label synchronized. A small local submit handler can now prove that Send and Enter reach the interface without contacting Ollama.
- In app.py, place your cursor after the last line of __init__().
- Add the state, log, and temporary submit methods by pasting this code:
def set_state(self, state):
color = STATE_COLORS.get(state, STATE_COLORS["error"])
self.canvas.itemconfig(self.orb, fill=color)
self.canvas.itemconfig(self.orb_text, text=state.upper())
def append_log(self, message):
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
def submit(self):
prompt = self.prompt_entry.get().strip()
if not prompt:
return
self.answer_text.set("Working locally...")
self.append_log("USER " + prompt)
self.set_state("thinking")
How does local feedback work?
- set_state() looks up the requested color. An unknown state falls back to red through the error entry.
- append_log() adds one event line before scrolling to the latest message.
- submit() ignores blank input. A real prompt changes the answer text, records the request, and moves the orb into its thinking state.
- Save app.py.
- Confirm the file now contains set_state(), append_log(), and submit() below __init__().
- In app.py, find the line containing self.prompt_entry.pack.
- Add the Enter binding and Send button directly below that line by pasting this code:
self.prompt_entry.bind("<Return>", lambda _event: self.submit())
self.send_button = tk.Button(
controls,
text="Send",
command=self.submit,
width=10,
)
self.send_button.pack(side="right")
How are requests submitted?
The Enter binding calls submit() when the prompt field receives a Return event. The Send button calls the same method, so both controls follow one path.
- Save app.py.
✔️ Awesome, I've got everything!
Your app.py now contains the complete local shell. Save the file before running the final checks.
ⓧ I'd like to double check the full code
Compare your app.py with this complete shell:
import tkinter as tk
STATE_COLORS = {
"waiting": "#2563eb",
"thinking": "#7c3aed",
"acting": "#f59e0b",
"speaking-ready": "#10b981",
"error": "#dc2626",
}
class AgentOrbApp:
def __init__(self, root):
self.root = root
self.root.title("Local Agent Orb")
self.root.geometry("680x650")
self.root.attributes("-topmost", True)
self.tools_enabled = tk.BooleanVar(value=True)
self.answer_text = tk.StringVar(
value="Ask a question, request the current time, or try arithmetic."
)
header = tk.Frame(root, padx=16, pady=12)
header.pack(fill="x")
self.canvas = tk.Canvas(
header,
width=180,
height=180,
bg="#111827",
highlightthickness=0,
)
self.canvas.pack()
self.orb = self.canvas.create_oval(
25,
25,
155,
155,
fill=STATE_COLORS["waiting"],
outline="#bfdbfe",
width=4,
)
self.orb_text = self.canvas.create_text(
90,
90,
text="WAITING",
fill="white",
font=("Segoe UI", 14, "bold"),
)
controls = tk.Frame(root, padx=16, pady=8)
controls.pack(fill="x")
self.prompt_entry = tk.Entry(controls, font=("Segoe UI", 11))
self.prompt_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
self.prompt_entry.bind("<Return>", lambda _event: self.submit())
self.send_button = tk.Button(
controls,
text="Send",
command=self.submit,
width=10,
)
self.send_button.pack(side="right")
tk.Checkbutton(
root,
text="Enable safe tools",
variable=self.tools_enabled,
).pack(anchor="w", padx=16)
answer_frame = tk.LabelFrame(root, text="Answer", padx=10, pady=10)
answer_frame.pack(fill="x", padx=16, pady=(8, 6))
tk.Label(
answer_frame,
textvariable=self.answer_text,
justify="left",
anchor="w",
wraplength=620,
).pack(fill="x")
log_frame = tk.LabelFrame(root, text="Agent Event Log", padx=8, pady=8)
log_frame.pack(fill="both", expand=True, padx=16, pady=(6, 16))
self.log_text = tk.Text(
log_frame,
height=14,
wrap="word",
font=("Consolas", 9),
)
self.log_text.pack(fill="both", expand=True)
def set_state(self, state):
color = STATE_COLORS.get(state, STATE_COLORS["error"])
self.canvas.itemconfig(self.orb, fill=color)
self.canvas.itemconfig(self.orb_text, text=state.upper())
def append_log(self, message):
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
def submit(self):
prompt = self.prompt_entry.get().strip()
if not prompt:
return
self.answer_text.set("Working locally...")
self.append_log("USER " + prompt)
self.set_state("thinking")
def main():
root = tk.Tk()
AgentOrbApp(root)
root.mainloop()
if __name__ == "__main__":
main()
Run the complete shell
The finished shell should open in a clean waiting state before any request changes it. This final check proves the layout, title bar, state feedback, and input controls work together.
Before you run it, which parts of the window do you expect to remain empty until you submit a prompt?
- Return to the Command Prompt terminal from earlier.
- Launch the complete shell by running this command:
python app.py
What does the final run verify?
The command loads every widget from AgentOrbApp before entering the event loop. The interface remains local because this file does not import or call the Ollama SDK yet.
You'll see a blue WAITING orb, a prompt field, a Send button, the checked tools option, an answer instruction, and an empty event log.
- Drag the window by its title bar to a different position on your desktop.
- Enter Hello orb in the prompt field.
- Click Send.
You'll see the orb turn purple with THINKING in its center. The answer changes to Working locally, while the event log records your USER message.
- Replace the prompt field contents with Show me another state.
- Press Enter.
- Clear the safe tools checkbox.
You'll see a second USER entry in the event log. The checkbox clears without affecting the rest of the responsive window.
Send or Enter does nothing?
Confirm submit() is aligned with set_state() inside the class. Check that the button and Enter binding both reference self.submit.
Help me debug the orb controls
That's the visual foundation working. Your orb now reveals local interface state changes without relying on model output.
Next, you'll connect the shell to qwen3:4b and experience why plain model text cannot verify live computer state.
Connect the Local Model
Your movable Tkinter shell already shows each visual state. Its answers still come from temporary local logic.
This step connects the shell to Ollama through the pinned Python SDK. You will also test what a plain model response can prove about your computer.
In this step, get ready to:
- Import the local model client into app.py.
- Send role-tagged messages to qwen3:4b.
- Compare an exact-time response with the Windows clock.
Add the local model client
The Ollama Python SDK gives your application a direct path to the local model. A model constant keeps the selected model identifier in one place.
- Switch back to app.py in Visual Studio Code.
- Paste the local model connection settings below the existing imports:
from ollama import ResponseError, chat
MODEL = "qwen3:4b"
MAX_TOOL_ROUNDS = 3
What does this code add?
- The chat function sends a conversation to the local model.
- ResponseError identifies model response failures that the application handles in a later step.
- MODEL stores the local model identifier used by every request.
- MAX_TOOL_ROUNDS places a fixed upper bound on model rounds.
- Save app.py.
- Confirm that the new import loads by running the application:
python app.py
What does this check prove?
Python loads the Ollama package before it creates the window. A visible orb window confirms that the pinned SDK import succeeds.
Your familiar orb window should open with its title bar and controls. The model connection code is now available to the application.
- Close the orb window to return control to the terminal.
Does the window fail to open?
Confirm that the terminal still shows the active tutorial-env environment. Check that requirements.txt still contains the pinned Ollama package.
Review the import placement if Python points to the first lines of app.py.
Help me troubleshoot the Ollama import
Build the model request
A model receives a sequence of messages with explicit roles. The system message sets the assistant's boundaries while the user message carries the current prompt.
- Add the request helper immediately above the AgentOrbApp class:
def run_agent(user_text, tools_enabled, emit):
"""Run a bounded Ollama conversation and optional tool loop."""
messages = [
{
"role": "system",
"content": (
"You are a concise local Windows assistant. "
"When tools are available, use get_current_time for live time "
"and calculate for arithmetic. Never claim a tool ran unless "
"a tool result appears in the conversation."
),
},
{"role": "user", "content": user_text},
]
for round_number in range(1, MAX_TOOL_ROUNDS + 1):
emit("state", "thinking")
emit("log", "MODEL ROUND " + str(round_number))
response = chat(
model=MODEL,
messages=messages,
think=False,
)
messages.append(response.message)
return response.message.content or "The model returned no text."
How does the request work?
- The system role defines the assistant's operating instructions.
- The user role preserves the learner's prompt as a separate message.
- think=False requests the direct response mode used by this CPU-first build.
- response.message.content provides the text that the orb displays.
- Save app.py.
- Check that the helper parses by starting the application again:
python app.py
What does this run check?
Python reads the complete run_agent() definition before it creates the interface. The window confirms that the message structure and function syntax are valid.
The orb should reopen with the same controls. Its request helper is ready to replace the temporary response.
- Close the orb window before editing the submit method.
Seeing a syntax problem?
Check the closing brackets around the messages list. Confirm that return remains indented inside the loop.
Help me fix the request helper
Display a local model response
The submit callback now needs to pass the prompt into run_agent(). This version performs the request inside the callback so you can focus on the model exchange first.
- Find the existing submit() method inside the AgentOrbApp class.
- Replace the complete method with this version:
def submit(self):
prompt = self.prompt_entry.get().strip()
if not prompt:
return
self.answer_text.set("Working locally...")
self.append_log("USER " + prompt)
self.set_state("thinking")
def emit(event_type, payload):
if event_type == "state":
self.set_state(payload)
elif event_type == "log":
self.append_log(payload)
answer = run_agent(prompt, False, emit)
self.answer_text.set(answer)
self.append_log("FINAL " + answer)
self.set_state("speaking-ready")
What changes when you submit?
- The prompt becomes the user_text value for the local request.
- False keeps safe tools disabled during this plain-chat test.
- emit() sends model-round updates into the existing orb state and event log methods.
- The final answer moves from response.message.content into the answer area.
- Save app.py.
- Clear the Enable safe tools checkbox.
- Start the connected orb by running:
python app.py
What happens during this run?
The application loads qwen3:4b through Ollama when you submit a prompt. The terminal stays occupied until you close the orb window.
- Enter Why does the Moon have phases? in the prompt field.
- Click Send.
You should see MODEL ROUND 1 in the event log. A locally generated explanation should appear in the answer area.
That is the connection working. Your orb now sends a real prompt to the local model and displays its reply.
Does the request fail?
Confirm that Ollama is running on Windows. Check that the local model from setup is still available.
The first request may take longer while the model loads into memory. Keep the application open while Ollama completes that load.
Help me troubleshoot the local request
Before you submit the next prompt, do you think its answer can prove that the model read the live Windows clock?
- Replace the prompt with What is the exact current time on this PC?.
- Click Send.
- Compare the response with the clock on the right side of the Windows taskbar.
The orb displays model-generated text. Its event log contains a model round and a final response.
You will not see TOOL REQUEST or TOOL RESULT in the log. The answer therefore has no verified observation from your computer's clock.
Your orb now speaks through a local model. Next, you will keep the window responsive while that model is working.
Keep the Interface Responsive
Your orb now answers through Ollama. However, each request currently occupies Tkinter's single event loop.
The interface cannot process movement or redraws while that request is running. You'll move inference to a background thread. A thread-safe queue will carry updates back to the GUI thread.
In this step, get ready to:
- Observe how synchronous inference blocks the Tkinter interface.
- Move model requests to a background worker.
- Process queued updates from the GUI thread without cross-thread widget access.
Experience the blocked event loop
Your current Send callback waits for the complete model response. That prevents Tkinter from handling window movement during inference.
Before you test it, do you think the orb will keep moving while the model prepares its answer?
- Switch back to the Command Prompt terminal from earlier.
- Start the current orb by running this command:
python app.py
What does this command do?
This starts the current version of app.py. The terminal remains occupied while the Tkinter event loop runs.
- Enter Explain why local AI can be useful. in the prompt field.
- Click Send.
- Drag the window by its title bar while the orb shows THINKING.
The window pauses or resists movement until the response finishes. That shortfall confirms the model call is blocking Tkinter's event loop.
- Close the orb by clicking the X in its title bar.
Why does the window freeze?
Tkinter processes clicks, movement, redraws, and scheduled callbacks on one GUI thread. A slow model request prevents that thread from processing its next event.
The model request belongs on a worker thread. Every widget update must stay on the GUI thread.
Move inference to a worker
The worker handles the slow Ollama request. The GUI thread remains responsible for the orb, answer label, event log, and Send button.
- Return to app.py in Visual Studio Code.
- Find the current Tkinter import near the top of the file:
import tkinter as tk
What are you changing?
The current import gives the application its GUI widgets. The replacement adds Python's thread and queue modules beside it.
- Replace that import with these three imports:
import queue
import threading
import tkinter as tk
What do these imports provide?
- The threading module runs the model request outside the GUI thread.
- The queue module safely transfers event data between the two threads.
- The tkinter module continues to own every visible widget.
- Find this line inside AgentOrbApp.__init__():
self.root.attributes("-topmost", True)
Where does the new state belong?
This line finishes the root window settings. The queue and request flag belong directly below it because both persist for the lifetime of the application.
- Add these application state values directly below that line:
self.events = queue.Queue()
self.request_in_flight = False
What does this state track?
- The events queue stores state changes, log entries, answers, errors, and completion signals from the worker.
- The request_in_flight flag prevents a second request from starting before the first one finishes.
- Find this line near the end of AgentOrbApp.__init__():
self.log_text.pack(fill="both", expand=True)
Where does polling start?
The event log is the final widget created by the constructor. Queue polling can start once the complete interface exists.
- Add this scheduled callback directly below that line:
self.root.after(100, self._pump_events)
How does polling work?
The after() call asks Tkinter to run _pump_events() after 100 milliseconds. The callback schedules itself again after processing the current queue.
- Find the existing submit() method.
- Replace the complete method with this version:
def submit(self):
if self.request_in_flight:
return
prompt = self.prompt_entry.get().strip()
if not prompt:
return
self.request_in_flight = True
self.send_button.config(state="disabled")
self.answer_text.set("Working locally...")
self.append_log("USER " + prompt)
self.set_state("thinking")
worker = threading.Thread(
target=self._worker,
args=(prompt, self.tools_enabled.get()),
daemon=True,
)
worker.start()
What does the new submit method do?
- The request flag rejects overlapping submissions.
- The disabled Send button makes the active request visible to the learner.
- The Thread starts _worker() without holding the Tkinter event loop.
- The daemon setting allows the worker to close with the application.
- Add the new _worker() method directly below submit() in app.py.
def _worker(self, prompt, tools_enabled):
def emit(event_type, payload):
self.events.put((event_type, payload))
try:
answer = run_agent(prompt, tools_enabled, emit)
emit("answer", answer)
except ResponseError as exc:
emit("error", "Ollama response error: " + exc.error)
except ConnectionError:
emit(
"error",
"Cannot reach Ollama. Start Ollama from the Windows Start menu and retry.",
)
except Exception as exc:
emit("error", "Unexpected error: " + str(exc))
finally:
emit("done", "")
How does the worker report results?
- The nested emit() function places event tuples into the queue.
- The ResponseError handler turns an Ollama response failure into a usable message.
- The ConnectionError handler explains how to restore an unavailable local service.
- The final handler keeps an unexpected exception from closing the application.
- Add the new _pump_events() method directly below _worker().
def _pump_events(self):
while True:
try:
event_type, payload = self.events.get_nowait()
except queue.Empty:
break
if event_type == "state":
self.set_state(payload)
elif event_type == "log":
self.append_log(payload)
elif event_type == "answer":
self.answer_text.set(payload)
self.append_log("FINAL " + payload)
self.set_state("speaking-ready")
elif event_type == "error":
self.answer_text.set(payload)
self.append_log("ERROR " + payload)
self.set_state("error")
elif event_type == "done":
self.request_in_flight = False
self.send_button.config(state="normal")
self.root.after(1200, lambda: self.set_state("waiting"))
self.root.after(100, self._pump_events)
Why is this thread-safe?
The worker only places data into self.events. It never changes a Tkinter widget.
The GUI thread drains the queue inside _pump_events(). Every call to set_state(), append_log(), and the widget configuration methods therefore stays on Tkinter's thread.
Seeing a name or indentation error?
Check that _worker() and _pump_events() align with submit() inside AgentOrbApp. Each method starts with four spaces.
Confirm that import queue and import threading appear at the top of app.py.
Help me debug the responsive worker changes.
Verify the responsive window
The model request now runs separately from Tkinter's event loop. This check proves that the GUI can continue processing movement while inference is active.
- Save app.py by pressing Ctrl+S.
Before you run the updated app, do you think the window will keep following your mouse while the orb shows THINKING?
- Switch back to the Command Prompt terminal from earlier.
- Start the updated orb by running this command:
python app.py
What does this run test?
This starts the threaded version of the orb. The normal Windows title bar remains available for the movement test.
- Enter Give me three uses for a local assistant. in the prompt field.
- Click Send.
- Check that the Send button becomes disabled.
- Drag the window by its title bar while the orb shows THINKING.
- Release the window in a different position.
The window continues moving while the request runs. The answer then appears without a cross-thread widget error.
The orb changes to SPEAKING-READY. The Send button becomes available again when the worker emits its completion event.
Does the window still freeze?
Confirm that worker.start() is inside submit(). Calling self._worker() directly would run inference on the GUI thread again.
Check that the worker uses self.events.put(). Widget methods must remain inside _pump_events().
Help me find why the Tkinter window still freezes.
You have separated slow local inference from the GUI event loop. Next, you'll let the model request trusted Python tools while keeping this responsive path intact.
Give the Model Safe Tools
Your responsive Tkinter orb can now wait for Ollama without freezing. However, its model response still cannot prove the current time or verify arithmetic through trusted Python code.
This step adds a bounded agent loop that exposes tool requests. It also feeds each observation back to qwen3:4b before the model writes its final answer.
In this step, get ready to:
- Define validated tools for local time and arithmetic.
- Build a three-round model-to-tool loop with visible event logs.
- Test successful tool calls plus bounded failure behavior.
Define the trusted tools
A safe tool is a Python function with a narrow purpose. The model may request that function, while your code keeps control of which names can run.
Why use the direct SDK?
The direct Ollama Python SDK keeps every message and stopping decision visible. You can see the complete model-to-tool workflow before introducing a higher-level agent framework.
- In app.py, replace the import group plus the model and state constants with this code:
import json
import queue
import threading
import tkinter as tk
from datetime import datetime
from ollama import ResponseError, chat
MODEL = "qwen3:4b"
MAX_TOOL_ROUNDS = 3
STATE_COLORS = {
"waiting": "#2563eb",
"thinking": "#7c3aed",
"acting": "#f59e0b",
"speaking-ready": "#10b981",
"error": "#dc2626",
}
What supports the tool loop?
- The json module serializes each tool request into readable JSON for the event log.
- The datetime import gives the time tool access to the computer's local clock.
- MAX_TOOL_ROUNDS limits one request to three model rounds.
- The acting color makes Python tool execution visible on the orb.
- Save app.py.
- Confirm the updated foundation still starts by running:
python app.py
What does this check prove?
The imports and constants load before the interface is created. You should see the existing blue orb without an import error.
- Close the orb window.
Does the orb close immediately?
Check the terminal for a Python error that points to the import group or STATE_COLORS. Compare the punctuation with the code above.
Ask for targeted help with the traceback: Help me fix the startup error
The time tool returns an ISO 8601 timestamp with a UTC offset. The calculator converts both operands to numbers before allowing four named operations.
- Add these tool contracts below STATE_COLORS in app.py:
def get_current_time() -> str:
"""Return the current local date, time, and UTC offset.
Returns:
The current local timestamp in ISO 8601 format.
"""
return datetime.now().astimezone().isoformat(timespec="seconds")
def calculate(operation: str, a: float, b: float) -> str:
"""Perform one validated arithmetic operation on two numbers.
Args:
operation: One of add, subtract, multiply, or divide.
a: The first number.
b: The second number.
Returns:
The numeric result or a validation error message.
"""
What do these contracts define?
- get_current_time() reads the local timestamp at the moment Python executes it.
- calculate() declares the operation plus two numeric operands that the model must supply.
- The docstrings describe the callable tools to the SDK.
- Add the calculator body below its docstring.
- Add the tool registry after the completed function using this code:
operation = operation.strip().lower()
left = float(a)
right = float(b)
if operation == "add":
value = left + right
elif operation == "subtract":
value = left - right
elif operation == "multiply":
value = left * right
elif operation == "divide":
if right == 0:
return "Error: division by zero is not allowed."
value = left / right
else:
return "Error: operation must be add, subtract, multiply, or divide."
if value.is_integer():
return str(int(value))
return str(value)
AVAILABLE_TOOLS = {
"get_current_time": get_current_time,
"calculate": calculate,
}
How is execution restricted?
- The operation branches accept only add, subtract, multiply, or divide.
- Division by zero returns a controlled error string.
- AVAILABLE_TOOLS maps approved names to approved Python functions.
- No model-provided text is passed to eval().
- Save app.py.
Seeing an indentation warning?
Keep the calculator body indented by four spaces beneath calculate(). Place AVAILABLE_TOOLS at the left edge after the function ends.
Ask for help checking the function boundary: Help me fix the tool indentation
Build the bounded agent loop
A tool request is an assistant message that names a function and supplies an argument object. Python executes the registered function before adding the result as a tool-role observation.
- Replace the previous model request helper with a new run_agent() function.
- Start the function with its conversation history and tool selection:
def run_agent(user_text, tools_enabled, emit):
"""Run a bounded Ollama conversation and optional tool loop."""
messages = [
{
"role": "system",
"content": (
"You are a concise local Windows assistant. "
"When tools are available, use get_current_time for live time "
"and calculate for arithmetic. Never claim a tool ran unless "
"a tool result appears in the conversation."
),
},
{"role": "user", "content": user_text},
]
tools = list(AVAILABLE_TOOLS.values()) if tools_enabled else None
What starts each conversation?
- messages preserves the system instruction plus the learner's prompt in role order.
- tools contains the registered Python callables only when the checkbox is enabled.
- The system instruction prevents the model from claiming an unobserved tool result.
- Continue run_agent() with the bounded model request:
for round_number in range(1, MAX_TOOL_ROUNDS + 1):
emit("state", "thinking")
emit("log", "MODEL ROUND " + str(round_number))
response = chat(
model=MODEL,
messages=messages,
tools=tools,
think=False,
)
messages.append(response.message)
tool_calls = response.message.tool_calls or []
if not tool_calls:
return response.message.content or "The model returned no text."
How does each round work?
- The range permits model rounds 1 through 3.
- chat() sends the complete message history plus the available tools to qwen3:4b.
- think=False keeps this CPU-first build focused on the response and tool calls.
- An empty tool-call list returns the model's final text immediately.
- Continue the same loop with registered function dispatch:
for call in tool_calls:
name = call.function.name
arguments = dict(call.function.arguments)
emit("state", "acting")
emit(
"log",
"TOOL REQUEST "
+ json.dumps(
{"name": name, "arguments": arguments},
ensure_ascii=False,
),
)
tool = AVAILABLE_TOOLS.get(name)
if tool is None:
result = "Error: requested tool is not registered."
else:
try:
result = tool(**arguments)
except (TypeError, ValueError) as exc:
result = "Error: invalid tool arguments: " + str(exc)
How does dispatch stay safe?
- The loop reads call.function.name plus call.function.arguments from each request.
- json.dumps() records the exact name and argument object in the event log.
- AVAILABLE_TOOLS.get(name) blocks any function that is absent from the registry.
- The exception handler converts invalid argument shapes into an observation that the model can read.
- Finish run_agent() with result logging and the stopping response:
emit(
"log",
"TOOL RESULT "
+ json.dumps(
{"name": name, "result": str(result)},
ensure_ascii=False,
),
)
messages.append(
{
"role": "tool",
"tool_name": name,
"content": str(result),
}
)
return "Stopped after reaching the three-round tool safety limit."
Why return the observation?
- TOOL RESULT exposes the function name plus its returned value in the event log.
- The tool role tells the model that this value came from function execution.
- The final return stops a conversation that still requests tools after three rounds.
- Save app.py.
Does the editor flag run_agent()?
Check that every line from for round_number through the stopping return remains inside run_agent(). Keep the tool loop nested beneath the model-round loop.
Ask for help checking the nested loops: Help me fix run_agent
Connect the loop to the orb
The background worker already protects the interface from model latency. You now need to pass the tool preference into that worker without moving any widget update off the GUI thread.
- In AgentOrbApp.__init__(), add the tool preference immediately after self.request_in_flight = False.
- Keep the answer text directly below it using this code:
self.tools_enabled = tk.BooleanVar(value=True)
self.answer_text = tk.StringVar(
value="Ask a question, request the current time, or try arithmetic."
)
What does the preference hold?
The BooleanVar stores whether tools are available for the next request. Its initial True value enables the safe registry by default.
- Find self.send_button.pack(side="right") in AgentOrbApp.__init__().
- Add the tool checkbox directly below that line:
tk.Checkbutton(
root,
text="Enable safe tools",
variable=self.tools_enabled,
).pack(anchor="w", padx=16)
What controls tool access?
The checkbox updates self.tools_enabled on the GUI thread. Each new worker receives a snapshot of that value when the request starts.
- In submit(), find the threading.Thread() call.
- Replace its args line with this line:
args=(prompt, self.tools_enabled.get()),
Why pass a snapshot?
The worker receives the prompt plus the checkbox value from submission time. Changing the checkbox during a request cannot alter the active conversation.
- Replace the existing _worker() method with this version:
def _worker(self, prompt, tools_enabled):
def emit(event_type, payload):
self.events.put((event_type, payload))
try:
answer = run_agent(prompt, tools_enabled, emit)
emit("answer", answer)
except ResponseError as exc:
emit("error", "Ollama response error: " + exc.error)
except ConnectionError:
emit(
"error",
"Cannot reach Ollama. Start Ollama from the Windows Start menu and retry.",
)
except Exception as exc:
emit("error", "Unexpected error: " + str(exc))
finally:
emit("done", "")
What stays on the worker thread?
- run_agent() performs every model request plus each Python tool call in the worker.
- emit() places state changes and log messages into the existing queue.
- The existing error handlers convert SDK and connection failures into GUI events.
- The done event always restores the interface after the worker exits.
- Save app.py.
✔️ Awesome, I've got everything!
Your safe tools, bounded loop, checkbox wiring, and background worker are ready. Double check that you saved app.py.
ⓧ I'd like to double check the full code
Compare your saved app.py with this complete version:
import json
import queue
import threading
import tkinter as tk
from datetime import datetime
from ollama import ResponseError, chat
MODEL = "qwen3:4b"
MAX_TOOL_ROUNDS = 3
STATE_COLORS = {
"waiting": "#2563eb",
"thinking": "#7c3aed",
"acting": "#f59e0b",
"speaking-ready": "#10b981",
"error": "#dc2626",
}
def get_current_time() -> str:
"""Return the current local date, time, and UTC offset.
Returns:
The current local timestamp in ISO 8601 format.
"""
return datetime.now().astimezone().isoformat(timespec="seconds")
def calculate(operation: str, a: float, b: float) -> str:
"""Perform one validated arithmetic operation on two numbers.
Args:
operation: One of add, subtract, multiply, or divide.
a: The first number.
b: The second number.
Returns:
The numeric result or a validation error message.
"""
operation = operation.strip().lower()
left = float(a)
right = float(b)
if operation == "add":
value = left + right
elif operation == "subtract":
value = left - right
elif operation == "multiply":
value = left * right
elif operation == "divide":
if right == 0:
return "Error: division by zero is not allowed."
value = left / right
else:
return "Error: operation must be add, subtract, multiply, or divide."
if value.is_integer():
return str(int(value))
return str(value)
AVAILABLE_TOOLS = {
"get_current_time": get_current_time,
"calculate": calculate,
}
def run_agent(user_text, tools_enabled, emit):
"""Run a bounded Ollama conversation and optional tool loop."""
messages = [
{
"role": "system",
"content": (
"You are a concise local Windows assistant. "
"When tools are available, use get_current_time for live time "
"and calculate for arithmetic. Never claim a tool ran unless "
"a tool result appears in the conversation."
),
},
{"role": "user", "content": user_text},
]
tools = list(AVAILABLE_TOOLS.values()) if tools_enabled else None
for round_number in range(1, MAX_TOOL_ROUNDS + 1):
emit("state", "thinking")
emit("log", "MODEL ROUND " + str(round_number))
response = chat(
model=MODEL,
messages=messages,
tools=tools,
think=False,
)
messages.append(response.message)
tool_calls = response.message.tool_calls or []
if not tool_calls:
return response.message.content or "The model returned no text."
for call in tool_calls:
name = call.function.name
arguments = dict(call.function.arguments)
emit("state", "acting")
emit(
"log",
"TOOL REQUEST "
+ json.dumps(
{"name": name, "arguments": arguments},
ensure_ascii=False,
),
)
tool = AVAILABLE_TOOLS.get(name)
if tool is None:
result = "Error: requested tool is not registered."
else:
try:
result = tool(**arguments)
except (TypeError, ValueError) as exc:
result = "Error: invalid tool arguments: " + str(exc)
emit(
"log",
"TOOL RESULT "
+ json.dumps(
{"name": name, "result": str(result)},
ensure_ascii=False,
),
)
messages.append(
{
"role": "tool",
"tool_name": name,
"content": str(result),
}
)
return "Stopped after reaching the three-round tool safety limit."
class AgentOrbApp:
def __init__(self, root):
self.root = root
self.root.title("Local Agent Orb")
self.root.geometry("680x650")
self.root.attributes("-topmost", True)
self.events = queue.Queue()
self.request_in_flight = False
self.tools_enabled = tk.BooleanVar(value=True)
self.answer_text = tk.StringVar(
value="Ask a question, request the current time, or try arithmetic."
)
header = tk.Frame(root, padx=16, pady=12)
header.pack(fill="x")
self.canvas = tk.Canvas(
header,
width=180,
height=180,
bg="#111827",
highlightthickness=0,
)
self.canvas.pack()
self.orb = self.canvas.create_oval(
25,
25,
155,
155,
fill=STATE_COLORS["waiting"],
outline="#bfdbfe",
width=4,
)
self.orb_text = self.canvas.create_text(
90,
90,
text="WAITING",
fill="white",
font=("Segoe UI", 14, "bold"),
)
controls = tk.Frame(root, padx=16, pady=8)
controls.pack(fill="x")
self.prompt_entry = tk.Entry(controls, font=("Segoe UI", 11))
self.prompt_entry.pack(side="left", fill="x", expand=True, padx=(0, 8))
self.prompt_entry.bind("<Return>", lambda _event: self.submit())
self.send_button = tk.Button(
controls,
text="Send",
command=self.submit,
width=10,
)
self.send_button.pack(side="right")
tk.Checkbutton(
root,
text="Enable safe tools",
variable=self.tools_enabled,
).pack(anchor="w", padx=16)
answer_frame = tk.LabelFrame(root, text="Answer", padx=10, pady=10)
answer_frame.pack(fill="x", padx=16, pady=(8, 6))
tk.Label(
answer_frame,
textvariable=self.answer_text,
justify="left",
anchor="w",
wraplength=620,
).pack(fill="x")
log_frame = tk.LabelFrame(root, text="Agent Event Log", padx=8, pady=8)
log_frame.pack(fill="both", expand=True, padx=16, pady=(6, 16))
self.log_text = tk.Text(
log_frame,
height=14,
wrap="word",
font=("Consolas", 9),
)
self.log_text.pack(fill="both", expand=True)
self.root.after(100, self._pump_events)
def set_state(self, state):
color = STATE_COLORS.get(state, STATE_COLORS["error"])
self.canvas.itemconfig(self.orb, fill=color)
self.canvas.itemconfig(self.orb_text, text=state.upper())
def append_log(self, message):
self.log_text.insert("end", message + "\n")
self.log_text.see("end")
def submit(self):
if self.request_in_flight:
return
prompt = self.prompt_entry.get().strip()
if not prompt:
return
self.request_in_flight = True
self.send_button.config(state="disabled")
self.answer_text.set("Working locally...")
self.append_log("USER " + prompt)
self.set_state("thinking")
worker = threading.Thread(
target=self._worker,
args=(prompt, self.tools_enabled.get()),
daemon=True,
)
worker.start()
def _worker(self, prompt, tools_enabled):
def emit(event_type, payload):
self.events.put((event_type, payload))
try:
answer = run_agent(prompt, tools_enabled, emit)
emit("answer", answer)
except ResponseError as exc:
emit("error", "Ollama response error: " + exc.error)
except ConnectionError:
emit(
"error",
"Cannot reach Ollama. Start Ollama from the Windows Start menu and retry.",
)
except Exception as exc:
emit("error", "Unexpected error: " + str(exc))
finally:
emit("done", "")
def _pump_events(self):
while True:
try:
event_type, payload = self.events.get_nowait()
except queue.Empty:
break
if event_type == "state":
self.set_state(payload)
elif event_type == "log":
self.append_log(payload)
elif event_type == "answer":
self.answer_text.set(payload)
self.append_log("FINAL " + payload)
self.set_state("speaking-ready")
elif event_type == "error":
self.answer_text.set(payload)
self.append_log("ERROR " + payload)
self.set_state("error")
elif event_type == "done":
self.request_in_flight = False
self.send_button.config(state="normal")
self.root.after(1200, lambda: self.set_state("waiting"))
self.root.after(100, self._pump_events)
def main():
root = tk.Tk()
AgentOrbApp(root)
root.mainloop()
if __name__ == "__main__":
main()
Before you run the finished orb, predict which prompts need Python observations. Think about which log entries would prove that a tool actually ran.
- Start the completed application by running:
python app.py
What starts now?
The command starts the existing Tkinter event loop with the new safe-tool path. You should see the blue WAITING orb plus the Enable safe tools checkbox.
- Keep Enable safe tools selected.
- Enter Name one reason local inference can be useful. in the prompt field.
- Click Send.
You should receive a normal answer. The log should contain a model round plus a final response without a tool request.
- Enter What is the exact current time on this PC? in the prompt field.
- Click Send.
You should see the orb move through THINKING and ACTING before reaching SPEAKING-READY. The log should show TOOL REQUEST for get_current_time followed by a timestamp in TOOL RESULT.
- Compare the returned time with the Windows clock.
- Enter What is 27 multiplied by 14? in the prompt field.
- Click Send.
The event log should show a calculate request with multiply, 27, and 14 in its argument object. The tool result should be 378.
- Enter Call calculate with operation modulo, a 10, and b 3. in the prompt field.
- Click Send.
When the model requests that unsupported operation, the tool result should contain Error: operation must be add, subtract, multiply, or divide. The application should remain open.
One final prompt tests the stopping rule. Before you submit it, predict what happens when the model asks for more sequential tool rounds than the loop permits.
- Enter Use calculate exactly once per model round. Start with 1 add 1. Use each result as a for the next add 1 request. Continue for four tool calls. in the prompt field.
- Click Send.
You should see no more than three model rounds. If the model continues requesting tools, the answer area should show Stopped after reaching the three-round tool safety limit.
Missing tool request logs?
Confirm that Enable safe tools is selected before submitting a new prompt. Check that AVAILABLE_TOOLS contains both registered function names.
Some model responses may group independent calls differently. Use prompts that explicitly name the intended function behavior when checking invalid input or the round limit.
Ask for help with the visible log: Help me debug the tool loop
You now have a responsive local agent that can request trusted Python functions and expose every observation. The next challenge deliberately breaks those paths so you can prove the same application recovers safely.
Secret mission
Break It Safely
Your orb works on the happy path. Now force its calculator to reject unsafe input before interrupting Ollama. Prove the same app instance recovers after the service returns.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project stays on your PC, so there are no ongoing cloud costs. Choose whether to keep your resources running, pause them for later, or delete them entirely.
Resources you used:
- The local-agent-orb project folder, including its tutorial-env virtual environment.
- The local qwen3:4b model, which uses approximately 2.5GB of storage.
- The Ollama application that runs your local model service.
Keep everything running
No action is needed. Choose this option if you want to continue testing or extending your local agent.
- Retain the local-agent-orb folder for future development.
- Leave Ollama running to keep the local model service available.
- Keep qwen3:4b downloaded for future prompts.
Pause - I'll come back to this later
Shut down the running applications to free up memory. Your project files plus downloaded model remain available for later.
- Close the Local Agent Orb window with its title bar close control.
- Quit Ollama from its Windows tray icon.
Your local-agent-orb folder stays on your PC. Your qwen3:4b model also remains available when you restart Ollama.
Delete - I don't want to use this again
A full reset removes the local model plus every file inside local-agent-orb. The first checklist item gives you a chance to preserve your evidence.
- Copy the evidence files outside the local-agent-orb folder if you want to keep them.
- Close the Local Agent Orb window with its title bar close control.
- Switch back to the Command Prompt terminal from earlier.
- Remove the local model plus check the remaining model list by running these commands:
ollama rm qwen3:4b
ollama ls
What Do These Commands Do?
- The first command removes the qwen3:4b model from local storage.
- The second command lists the models that remain on your PC.
That is the biggest cleanup done. You should see a refreshed model list that no longer contains qwen3:4b.
Model Still Listed?
- Confirm Ollama is running from the Windows Start menu.
- Retry the removal command from the Command Prompt terminal.
Ask for help if the model still appears in the list.
Uninstalling Ollama removes the local model service from Windows. Your project folder remains untouched until the final cleanup action.
- Press the Windows key to open Windows search.
- Type Add or remove programs into the search field.
- Press Enter.
- Find Ollama in the installed applications.
- Use the uninstall control for Ollama.
- Complete the Windows removal prompts.
Ollama should no longer appear in the installed applications list.
Deleting the project folder also removes app.py plus your virtual environment. Your saved evidence disappears unless you copied it outside the folder.
- Return to the Command Prompt terminal inside local-agent-orb.
- Remove the project folder plus display its former parent folder by running these commands:
cd ..
rmdir /s /q local-agent-orb
dir
What Do These Commands Do?
- The first command moves the terminal outside the project folder.
- The second command deletes the project folder plus all of its contents.
- The third command lists the parent folder so you can verify the deletion.
You should see the parent folder contents without local-agent-orb in the list. Your local project resources are now removed.
Folder Still Listed?
- Close Visual Studio Code to release any files still in use.
- Retry the deletion commands from a Command Prompt opened in the folder above local-agent-orb.
Ask for help if Windows still prevents the deletion.
Nice Work!
Nice Work!
You did it! You've built an always-on-top Tkinter orb that runs Qwen3 4B locally through Ollama. Its bounded agent loop exposes each model round through a visible event log.
You've learned how to:
- Build a responsive desktop orb with four visible activity states. Move model work onto a background thread. Route worker events through a thread-safe queue.
- Connect a local Qwen3 4B model through Ollama. Expose the limitation of plain chat by testing a live time request without tools.
- Run a bounded tool loop with registered time functions. Restrict arithmetic to approved operations. Inspect JSON-style arguments plus tool results in the event log.
- Secret Mission: Prove safe failure recovery through invalid arithmetic. Stop the local model service to trigger a handled connection error. Restart Ollama to confirm that the same running app recovers.
Ready to quiz yourself?