Build an AI Planner Agent with Ollama
Build a local AI planner agent that manages tasks using tool calling.
Introduction
30 Second Summary
Everyone has a growing list of things to do. Tasks pile up, deadlines overlap, and keeping track of everything across sticky notes, apps, and mental checklists never quite works.
In this project, you will build a terminal-based AI planner agent powered by Ollama, a tool for running LLMs locally on your own machine. You will type requests like "I need to finish the report by Friday" and watch the agent reason about them, save your tasks, and respond in plain English.
What You'll Build
Picture typing "Plan my week" into your terminal and watching your AI agent pull up everything on your plate, organize it by priority, and present a structured plan in seconds.
By the end of this project, you'll have:
- A local AI planner that saves tasks with timeframes, priorities, and categories when you describe them in everyday language. No cloud APIs, no API keys, no data leaving your computer.
- Structured outputs that reliably extract task details from messy text into clean, validated fields using Pydantic and JSON schema.
- A full agent loop where the model autonomously decides which Python functions to call, reads their results, and composes natural language answers.
- Secret Mission: Add reminder tools and enable thinking mode to see the agent's chain-of-thought reasoning before it decides what to do.
Are there any prerequisites?
This project includes setup for all required tools. You just need Python 3.10 or newer and VS Code installed on your machine. Everything runs locally at no cost.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Ollama and Your VS Code Workspace
Your AI planner needs a brain, and that brain is a large language model running directly on your machine. Ollama makes this possible by letting you download and run LLMs locally, with no cloud APIs or API keys required.
Before you can write a single line of Python against a local model, you need Ollama installed and running, plus a proper VS Code workspace. In this step, you will set up both.
In this step, get ready to:
- Install Ollama and pull the qwen3:1.7b model.
- Create and open an ai-planner project folder in VS Code.
- Set up a Python virtual environment and install packages.
Install Ollama and pull the model
Ollama is a tool that runs large language models on your local machine. You will download it, install it, then pull a small model your agent will use for the rest of this project.
- Open your browser and go to the Ollama download page:
- Click Download for Windows.
- Run the downloaded OllamaSetup.exe file.
- Follow the installer prompts to complete the installation.
What is Ollama?
Ollama is a tool that lets you download, run, and manage large language models entirely on your own computer. No data leaves your machine, and no API keys are needed.
After installation, Ollama runs as a background service automatically. It serves a local API at http://localhost:11434 that your Python scripts will talk to.
Now you need to download the model itself. Ollama is the engine, and the model is what actually generates responses.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Pull the qwen3:1.7b model by running this command:
ollama pull qwen3:1.7b
What is qwen3:1.7b?
This is a small but capable language model from the Qwen family. The 1.7b version is a 1.4GB download that fits comfortably in 8GB of RAM.
It supports tool calling and structured outputs natively. These are the two key features you will use throughout this project to build your planner agent.
You will see a progress bar in the terminal as the model downloads. Wait for it to complete before continuing.
- Confirm Ollama is working by running this command:
ollama --version
- You should see a version number printed, such as ollama version 0.32.15 or similar.
Don't see a version number?
- Make sure you completed the Ollama installer. Try running OllamaSetup.exe again from your Downloads folder.
- Close PowerShell and reopen it. The ollama command may not be available in terminals that were open during installation.
- help me troubleshoot Ollama installation on Windows
Create your VS Code workspace
Your coding workspace needs a dedicated project folder. You will create one on your Desktop, then open it in VS Code so every file and command stays organized in one place.
- In the PowerShell window you already have open, move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running this command:
mkdir ai-planner
- Confirm the folder exists by running:
ls ai-planner
- You should see the command run without an error. The folder is empty, so no files are listed yet.
Now open the folder in VS Code.
- Press the Windows key to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the ai-planner folder, and click Select Folder.
VS Code now shows ai-planner in the Explorer sidebar on the left. This is your workspace for the rest of the project.
- Open the integrated terminal by pressing Ctrl+` (the backtick key, located above Tab on most keyboards).
A terminal panel opens at the bottom of VS Code. It starts inside your ai-planner folder automatically.
What is the integrated terminal?
The integrated terminal is a command-line panel built into VS Code. It starts in the folder you opened, so any files you create or commands you run happen inside ai-planner automatically. You will use it for every command in this project.
Don't see a terminal panel?
- Try the keyboard shortcut again: Ctrl+` (backtick, not a single quote).
- Alternatively, click Terminal in the top menu bar, then click New Terminal.
- help me open the integrated terminal in VS Code
Create a virtual environment and install packages
A Python virtual environment is an isolated space for your project's packages. It keeps this project's dependencies separate from other Python projects on your machine.
- Create a virtual environment by running this command in the VS Code terminal:
python -m venv venv
What does this command do?
This tells Python to create a virtual environment in a folder called venv inside your ai-planner directory. Any packages you install next go into this isolated folder instead of your system-wide Python.
- Activate the virtual environment by running:
venv\Scripts\activate
- You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Seeing a 'running scripts is disabled' error?
PowerShell may block script execution by default. You will see a message like Activate.ps1 cannot be loaded because running scripts is disabled on this system.
- Fix this by running the following command in the same terminal, then try activating again:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
- When prompted to confirm, type Y and press Enter.
- Then run venv\Scripts\activate again.
- help me fix the PowerShell execution policy error when activating a Python virtual environment
- Install the ollama and pydantic packages by running:
pip install ollama pydantic
- You should see pip download and install both packages along with their dependencies. Look for Successfully installed near the end of the output.
What are these packages?
The ollama package is the official Python SDK for talking to your local Ollama instance. It provides the chat function you will use in every script.
The pydantic package helps you define data structures and validate data. You will use it in Step 3 to force the model into returning structured JSON.
Seeing a 'pip not found' error?
- Make sure you see (venv) at the start of your terminal prompt. If not, run venv\Scripts\activate first.
- If you closed the terminal, open a new one with Ctrl+` and activate the virtual environment again before running pip install.
- help me fix pip not found when installing Python packages in VS Code
Time to verify everything is wired up. Before you run the next two commands, what do you think each one will show?
- Verify Ollama is accessible from the VS Code terminal by running:
ollama --version
- You should see a version number printed.
- Now verify the Python SDK is installed by running:
python -c "from ollama import chat; print('SDK ready')"
- You should see SDK ready printed in the terminal.
Seeing 'ModuleNotFoundError'?
- Make sure you see (venv) in your terminal prompt. If not, activate the virtual environment again with venv\Scripts\activate.
- If you opened a new terminal tab, you need to activate the virtual environment in that tab before running the command.
- help me fix ModuleNotFoundError for the ollama package in Python
Your local AI engine is running and your workspace is ready. Next up, you will write your first Python script and have a conversation with the model.
Write Your First Ollama Chat in Python
Ollama is running, the SDK is ready, and your VS Code workspace is set up. Before building anything complex, you need to confirm the model actually responds to your Python code.
In this step, you'll write a short script that sends a message to your local model and prints the response. This is the core pattern every Ollama application is built on.
In this step, get ready to:
- Send a chat message to your local qwen3:1.7b model from a Python script.
- Modify the prompt and observe how different inputs produce different responses.
Create chat.py and send your first message
Every Ollama Python script follows the same core pattern: import the chat function, pass it a model name and a list of messages, and read the response. You'll start with a simple greeting to confirm the pipeline works end to end.
- In the Explorer sidebar on the left side of VS Code, hover over the folder name at the top.
- Click the New File... icon (it looks like a file with a small +).
- Type chat.py and press Enter.
The new file opens in the editor. VS Code is ready for you to start typing.
- Paste the following code into chat.py:
from ollama import chat
# Send a single message to the local model
response = chat(
model='qwen3:1.7b',
messages=[
{'role': 'user', 'content': 'Hello! What can you help me with?'},
],
think=False,
)
# Print the model's text reply
print(response.message.content)
What does this code do?
- chat() sends a conversation to your local model and returns its response. You pass it the model name and a list of messages.
- messages is a list of dictionaries. Each dictionary has a role (who is speaking) and content (what they say).
- think=False disables the model's extended reasoning mode. This keeps responses fast on CPU.
- response.message.content holds the model's text reply as a plain string.
- Save chat.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
What do you think will happen when you run this? Will the response appear right away, or will it take a moment on your CPU?
- Run your script in the integrated terminal by entering this command:
python chat.py
After a few seconds, the model's response appears in the terminal. Since the model runs entirely on your CPU, it takes a moment to generate its answer rather than responding instantly.
Not seeing a response?
- Make sure Ollama is still running. Open a new terminal and run ollama list to confirm qwen3:1.7b is listed.
- Check that your virtual environment is active. You should see its name in parentheses at the start of your terminal prompt.
- If you see ModuleNotFoundError, the ollama package is not installed in this environment. Run pip install ollama and try again.
Still stuck? Help me debug why my chat.py script is not returning a response from the Ollama model.
Change the prompt and observe a different response
The chat function produces different responses depending on the prompt you send. Let's swap the greeting for a question about planning to confirm this.
- In chat.py, find this line inside the messages list:
{'role': 'user', 'content': 'Hello! What can you help me with?'},
- Replace the content value so the line now looks like this:
{'role': 'user', 'content': 'What are some tips for planning a productive day?'},
- Save chat.py.
Before you run this, do you think the response will cover the same topics as before, or shift to match the new prompt?
- Run the script again to see the new response:
python chat.py
This time the model responds with planning and productivity tips. Same function, same model, completely different output. The prompt is the steering wheel.
Seeing the same response as before?
- Double check that you saved chat.py after editing. A white dot on the file tab in VS Code means unsaved changes.
- Confirm you replaced the entire content value, including the surrounding quotes.
Still stuck? My chat.py script keeps returning the same response even though I changed the prompt.
✔️ Awesome, I've got everything!
Great. Double check that you've saved chat.py with the updated prompt.
ⓧ I'd like to double check the full code
Here is the complete chat.py file. Make sure yours matches exactly:
from ollama import chat
response = chat(
model='qwen3:1.7b',
messages=[
{'role': 'user', 'content': 'What are some tips for planning a productive day?'},
],
think=False,
)
print(response.message.content)
Your model talks back. Next up, you'll teach it to respond in a perfectly structured format that your code can reliably parse.
Force Structure with Structured Outputs
Your chat.py script can send any question to the model and get a response. That is the core pattern you will use for the rest of this project.
There is a problem, though. The model returns free-form text by default. If you ask it to extract task details from a sentence like "I need to finish the report by Friday," the response format changes every time. One run might give bullet points, the next a paragraph. Your code has no reliable way to parse the task name, timeframe, or priority from that unpredictable output.
In this step, get ready to:
- Observe how the model returns inconsistent free-form text when asked to extract task details.
- Create a Pydantic model to define the exact data structure you want.
- Use the format parameter to force perfectly structured JSON output.
Ask the model to extract task details
Before building a fix, you need to see the problem firsthand. You will change the prompt in chat.py to ask the model to extract structured information from a sentence. This is a temporary change to demonstrate the issue.
- In chat.py, find this line inside the messages list:
'content': 'What are some tips for planning a productive day?'
- Replace it with a prompt that asks for task extraction, so the line now looks like this:
'content': 'Extract the task details from this text: I need to finish the report by Friday, high priority'
Before you run this, what format do you think the model's response will be in? Will it give you something your code could easily parse?
- Run the modified script to see the response:
python chat.py
You will see a free-form text response. It might be a paragraph, a list of bullet points, or a table. Run it a second time and the format will likely change.
Why is this a problem?
Your planner agent needs to save tasks to a file with specific fields like name, timeframe, and priority. If the model returns a different format every time, your Python code has no reliable way to pull those values out. You would need fragile string parsing that breaks whenever the model changes its mind about how to format the answer.
The fix is structured outputs. You will solve this in a new file. First, undo your changes to chat.py so it stays as a reference.
- Press Cmd+Z (macOS) or Ctrl+Z (Windows) a few times until the prompt reads 'What are some tips for planning a productive day?' again.
- Save chat.py.
Define a Task model for structured output
Pydantic lets you define a Python class that describes the exact shape of data you want. The Ollama SDK can take that shape as a JSON schema and force the model to respond in that exact format.
- Create a new file called parse_task.py in the ai-planner folder by clicking the new file icon at the top of the Explorer sidebar in VS Code.
- Add the imports and Task class by copying this code into parse_task.py:
from ollama import chat
from pydantic import BaseModel
class Task(BaseModel):
name: str
timeframe: str
priority: str
category: str
What does this code do?
The Task class inherits from Pydantic's BaseModel. Each field (name, timeframe, priority, category) is typed as str. Pydantic can automatically generate a JSON schema from this class, which tells the model exactly what fields to fill in and what types they should be.
Send a structured request and validate the response
Now you will use the format parameter to pass the Task schema to the model. Then you will validate the response and print each field on its own labeled line.
- Add the chat call, validation, and print statements below the Task class in parse_task.py:
response = chat(
model='qwen3:1.7b',
messages=[
{
'role': 'user',
'content': 'Extract the task details from this text: I need to finish the project report by end of this week, it is high priority work stuff',
},
],
format=Task.model_json_schema(),
think=False,
)
task = Task.model_validate_json(response.message.content)
print(f"Task: {task.name}")
print(f"Timeframe: {task.timeframe}")
print(f"Priority: {task.priority}")
print(f"Category: {task.category}")
What does this code do?
The format=Task.model_json_schema() parameter tells Ollama to enforce the Task schema on the model's response. Instead of free-form text, the model is forced to return valid JSON with exactly the fields you defined.
After the response comes back, Task.model_validate_json() parses the raw JSON string into a proper Python Task object. If the JSON is malformed or missing fields, Pydantic raises an error instead of letting bad data slip through silently.
- Save parse_task.py.
Before you run this, what do you think the output will look like this time? Will it be free-form text again, or something different?
- Run the script to see structured output in action:
python parse_task.py
You will see four cleanly labeled lines printed. The task name, timeframe, priority, and category are each extracted from the input sentence and displayed on their own line. Every run produces the same format.
Not seeing four labeled lines?
- Make sure you saved parse_task.py before running. Unsaved changes do not take effect.
- Check that the Task class and the chat call are both in the same file. If they are in separate files, the format parameter cannot reference the class.
- If you see a connection error, confirm Ollama is still running by checking for its icon in your system tray.
Why isn't my parse_task.py producing structured output?
✔️ Awesome, I've got everything!
Great. Double check you have saved parse_task.py and that chat.py still has its original prompt.
ⓧ I'd like to double check the full code
Here is the complete parse_task.py file. Make sure your code matches this exactly:
from ollama import chat
from pydantic import BaseModel
class Task(BaseModel):
name: str
timeframe: str
priority: str
category: str
response = chat(
model='qwen3:1.7b',
messages=[
{
'role': 'user',
'content': 'Extract the task details from this text: I need to finish the project report by end of this week, it is high priority work stuff',
},
],
format=Task.model_json_schema(),
think=False,
)
task = Task.model_validate_json(response.message.content)
print(f"Task: {task.name}")
print(f"Timeframe: {task.timeframe}")
print(f"Priority: {task.priority}")
print(f"Category: {task.category}")
Your chat.py should still look like this:
from ollama import chat
response = chat(
model='qwen3:1.7b',
messages=[
{'role': 'user', 'content': 'What are some tips for planning a productive day?'},
],
think=False,
)
print(response.message.content)
You have just solved one of the biggest challenges of working with LLMs: getting reliable, parseable output. Next up, you will use this same pattern to build the actual planner tools that save tasks and query your schedule.
Build and Connect Your Planner Tools
Your model can extract structured data from text thanks to parse_task.py. But it still cannot take actions on your behalf. It has no way to save a task, look up your schedule, or pull up an overview.
In this step, you'll build three Python functions that give the model those abilities. You'll connect them to the LLM using Ollama's tool calling feature and watch the model decide which function to call on its own.
In this step, get ready to:
- Build three tool functions for task management with local JSON storage.
- Connect the tools to the model using the tools parameter.
- Test the agent and discover why it cannot compose a conversational response yet.
Create the tool functions and file storage
Your planner needs a way to store tasks on disk and functions that the model can call. You'll start by creating a new file with the storage helpers and your first tool function.
- In the VS Code Explorer sidebar, hover over the ai-planner folder name and click the new file icon.
- Name the file planner.py and press Enter.
- Add the imports and file storage helpers by copying this code into planner.py:
import json
from datetime import datetime
from pathlib import Path
from ollama import chat
# Path to the JSON file where tasks are stored on disk
TASKS_FILE = Path("tasks.json")
def load_tasks():
# Read existing tasks from disk, or return an empty list
if TASKS_FILE.exists():
return json.loads(TASKS_FILE.read_text())
return []
def save_tasks(tasks):
# Write the full task list back to disk as formatted JSON
TASKS_FILE.write_text(json.dumps(tasks, indent=2))
What does this code do?
TASKS_FILE points to a tasks.json file in your project folder. This file acts as your planner's database.
load_tasks() reads that file and returns a list of task dictionaries. If the file doesn't exist yet, it returns an empty list so your code never crashes on a missing file. save_tasks() writes the full list back to disk.
- Add the add_task function below save_tasks by copying this code:
def add_task(name: str, timeframe: str, priority: str, category: str) -> str:
"""Add a new task to the planner.
Args:
name: A short description of the task
timeframe: When this task should be done such as today, this_week, or this_month
priority: The priority level such as high, medium, or low
category: The type of task such as work, personal, health, or errands
Returns:
A confirmation message with the task details
"""
tasks = load_tasks()
# Build a task dictionary with metadata
task = {
"name": name,
"timeframe": timeframe,
"priority": priority,
"category": category,
"added": datetime.now().strftime("%Y-%m-%d %H:%M"),
"done": False,
}
tasks.append(task)
save_tasks(tasks)
return f"Added task: '{name}' [{priority} priority, {category}] for {timeframe}"
Why are the docstrings so detailed?
These are Google-style docstrings. The Ollama Python SDK automatically parses them into tool schemas that the model can understand. The Args section tells the model what each parameter means, and the Returns section describes the output.
You don't need to write any JSON schema by hand. Just pass the Python function directly to the tools parameter and the SDK handles the rest.
- Save planner.py.
- Test add_task directly by running this command in the integrated terminal:
python -c "from planner import add_task; print(add_task('Test task', 'today', 'high', 'work'))"
You should see Added task: 'Test task' [high priority, work] for today printed in the terminal.
- Confirm tasks.json was created by running:
ls
You should see tasks.json listed alongside chat.py, parse_task.py, and planner.py.
Seeing an import error?
Make sure your virtual environment is still active in the terminal. You should see (venv) at the start of your terminal prompt.
Check that the file is saved as planner.py in the ai-planner folder, not inside a subfolder.
Why can't Python find my planner module?
Now add the two retrieval functions that let the model look up tasks.
- Add the get_tasks function below add_task in planner.py:
def get_tasks(timeframe: str) -> str:
"""Get all tasks for a specific timeframe.
Args:
timeframe: The timeframe to filter by such as today, this_week, or this_month
Returns:
A formatted list of tasks for that timeframe or a message if none found
"""
tasks = load_tasks()
# Filter to only pending tasks that match the requested timeframe
matches = [t for t in tasks if t["timeframe"].lower() == timeframe.lower() and not t["done"]]
if not matches:
return f"No tasks found for {timeframe}."
result = f"Tasks for {timeframe} ({len(matches)} total):\n"
for t in matches:
result += f" - [{t['priority']}] {t['name']} ({t['category']})\n"
return result
What does this code do?
get_tasks filters your task list by timeframe (like today or this_week) and only returns tasks that aren't marked as done. The model will call this when someone asks about a specific time period.
- Add the get_all_tasks function and the AVAILABLE_TOOLS dictionary below get_tasks:
def get_all_tasks() -> str:
"""Get a summary of all tasks across all timeframes.
Returns:
A summary showing all pending tasks grouped by timeframe
"""
tasks = load_tasks()
pending = [t for t in tasks if not t["done"]]
if not pending:
return "No pending tasks. Your schedule is clear!"
# Group pending tasks by their timeframe
grouped = {}
for t in pending:
tf = t["timeframe"]
if tf not in grouped:
grouped[tf] = []
grouped[tf].append(t)
result = f"All pending tasks ({len(pending)} total):\n"
for tf, task_list in grouped.items():
result += f"\n {tf.upper()}:\n"
for t in task_list:
result += f" - [{t['priority']}] {t['name']} ({t['category']})\n"
return result
# Map function names to their Python functions for dispatch
AVAILABLE_TOOLS = {
"add_task": add_task,
"get_tasks": get_tasks,
"get_all_tasks": get_all_tasks,
}
What does this code do?
get_all_tasks() retrieves every pending task and groups them by timeframe. The model will call this when someone asks for a full overview or says something like "plan my week."
AVAILABLE_TOOLS is a dictionary that maps each tool's name (as a string) to the actual Python function. When the model says "call add_task", your code uses this dictionary to look up and run the right function.
- Save planner.py.
- Test get_tasks with the test task you created earlier by running:
python -c "from planner import get_tasks; print(get_tasks('today'))"
You should see your test task listed with its priority and category.
Seeing no tasks found?
Make sure the earlier add_task test ran successfully and created tasks.json. Click on tasks.json in the Explorer sidebar to check its contents.
Why isn't get_tasks finding my test task?
Write the agent function that connects tools to the model
Your tool functions work, but the model has no way to call them yet. You need a function that sends a user message to the model along with a list of available tools, then executes whichever function the model chooses.
- Add the run_agent function below AVAILABLE_TOOLS in planner.py. Start with the message setup and the chat call:
def run_agent(user_input):
# Build the conversation with a system prompt and the user's message
messages = [
{
"role": "system",
"content": (
"You are a personal planner assistant. "
"Use the provided tools to help the user manage their schedule and tasks. "
"When the user mentions something they need to do, use add_task to record it. "
"When they ask about their schedule for a specific timeframe, use get_tasks. "
"When they want a full overview or ask you to plan, use get_all_tasks. "
"For general questions or conversation, respond directly without tools. "
"Always be helpful and proactive about organizing their time."
),
},
{"role": "user", "content": user_input},
]
# Send the message to the model along with the available tools
response = chat(
model="qwen3:1.7b",
messages=messages,
tools=[add_task, get_tasks, get_all_tasks],
think=False,
)
# If the model chose not to call any tools, print its response directly
if not response.message.tool_calls:
print(f"\nPlanner: {response.message.content}")
return
What does this code do?
The messages list starts with a system prompt that tells the model how to behave. This is where you instruct the model to use add_task for new tasks, get_tasks for schedule queries, and get_all_tasks for overviews.
The tools parameter passes your three Python functions directly. The SDK reads each function's Google-style docstring and converts it into a tool schema the model understands. If the model decides no tool is needed, response.message.tool_calls will be empty and the function prints the model's text response.
- Add the tool execution code at the end of the run_agent function, directly below the return statement you just wrote:
# Execute each tool the model requested
for tool_call in response.message.tool_calls:
tool_name = tool_call.function.name
tool_args = tool_call.function.arguments
if tool_fn := AVAILABLE_TOOLS.get(tool_name):
print(f" [Calling {tool_name}({tool_args})]")
result = tool_fn(**tool_args)
print(f" [Result: {result}]")
else:
result = f"Unknown tool: {tool_name}"
What does this code do?
When the model decides to call a tool, the response contains a list of tool_calls. Each one has a function.name (like add_task) and a function.arguments dictionary with the values the model extracted from the user's message.
The := is Python's walrus operator. It looks up the function name in AVAILABLE_TOOLS and assigns the result to tool_fn in one step. If the function exists, it gets called with the model's arguments using **tool_args to unpack the dictionary into keyword arguments.
- Save planner.py.
- Confirm run_agent imports cleanly by running:
python -c "from planner import run_agent; print('Agent ready')"
You should see Agent ready printed. If you see a syntax error instead, double check that the for loop is indented inside run_agent at the same level as the if not response.message.tool_calls: block above it.
Add the input loop and test the agent
The agent function is ready. Now you need a way to keep the conversation going so you can type multiple messages without restarting the script.
- Add the main function and entry point at the bottom of planner.py, below run_agent:
def main():
print("AI Planner (type 'quit' to exit)")
print("-" * 35)
# Keep prompting until the user types quit or exit
while True:
user_input = input("\nYou: ").strip()
if user_input.lower() in ("quit", "exit"):
break
if not user_input:
continue
run_agent(user_input)
if __name__ == "__main__":
main()
What does this code do?
The main() function creates an interactive loop. It keeps asking for input, passing each message to run_agent, and printing the result until you type quit or exit.
The if __name__ == "__main__": guard ensures main() only runs when you execute the file directly. This is why the earlier python -c "from planner import ..." tests worked without starting the input loop.
- Save planner.py.
Before you run the planner, what do you think will happen when you tell the agent about a task? Will it just save the task silently, or will it respond with a confirmation message?
- Start the planner by running:
python planner.py
You should see the AI Planner (type 'quit' to exit) header and a You: prompt waiting for input.
- Type the following and press Enter:
I need to submit the report by end of this week, it's high priority work
This may take a few seconds since the model is running on your CPU. You should see something like [Calling add_task({...})] printed in the terminal, followed by the result confirming the task was added.
- Click on tasks.json in the Explorer sidebar to confirm your new task entry is there.
Now try asking the agent about your schedule. What do you expect to happen?
- At the You: prompt, type:
What do I have this week?
You'll see [Calling get_tasks({...})] and the raw result printed. But notice what's missing. The agent calls the tool and prints the result, then the prompt immediately returns to You:. There's no Planner: message. The model never composes a friendly response because it never sees what the tool returned.
Why doesn't the agent respond after calling a tool?
Right now, run_agent calls chat once, executes the tool, and stops. The tool results are printed to your terminal, but they are never sent back to the model. The model has no idea what your functions returned.
This is the gap you'll fix in the next step. You'll close the loop by feeding tool results back into the conversation so the model can read them and compose a natural language response.
- Type quit to exit the planner.
✔️ Awesome, I've got everything!
Make sure you've saved planner.py before moving on.
ⓧ I'd like to double check the full code
Your planner.py is built from six chunks. Scroll up and confirm each one is present in this order:
- Imports, TASKS_FILE, load_tasks, and save_tasks.
- add_task function with Google-style docstring.
- get_tasks function with Google-style docstring.
- get_all_tasks function and AVAILABLE_TOOLS dictionary.
- run_agent function with messages, chat call, and tool execution loop.
- main function and if __name__ == "__main__": entry point.
Model not calling tools?
If the model responds with text instead of calling a tool, try rephrasing your input more explicitly. For example, "Add a task called 'submit report' for this week, high priority, category work." Small models sometimes need clearer instructions.
If you see no output at all for a long time, the model may still be processing. CPU inference with qwen3:1.7b can take up to 10-15 seconds on some machines. Wait for the response before trying again.
Why isn't the model calling my tool functions?
Your tools work and the model knows how to call them. The missing piece is the return trip. In the next step, you'll complete the agent loop so the model sees what your tools returned and composes a real response.
Complete the Agent Loop
In the last step, you connected your tool functions to the model and watched it decide which tool to call. But you also saw the gap: the model calls your tools and prints results to the terminal, then stops. It never reads those results or composes a response from them.
The fix is an agent loop. Instead of calling the model once, you wrap the chat call in a loop that feeds tool results back as messages. The model reads the results, decides if it needs more tools, and eventually responds with a natural language answer.
In this step, get ready to:
- Wrap the chat call in a while loop that re-calls the model after each round of tool calls.
- Append tool results to the conversation so the model can compose natural language responses.
- Test the full agent with multi-task input, schedule queries, and general conversation.
Wrap the chat call in a while loop
Right now, run_agent calls the model once. If the model requests tool calls, your code executes them and prints the results, but the model never sees those results. You need a loop that sends tool results back and lets the model respond again.
- In planner.py, scroll down to the run_agent function.
- Find this block of code right after the messages list:
response = chat(
model="qwen3:1.7b",
messages=messages,
tools=[add_task, get_tasks, get_all_tasks],
think=False,
)
if not response.message.tool_calls:
print(f"\nPlanner: {response.message.content}")
return
- Replace that block with the following, which wraps the chat call inside a while True loop and changes return to break:
while True:
response = chat(
model="qwen3:1.7b",
messages=messages,
tools=[add_task, get_tasks, get_all_tasks],
think=False,
)
if not response.message.tool_calls:
print(f"\nPlanner: {response.message.content}")
break
- Select the entire for tool_call in response.message.tool_calls: block below (including everything inside it) and indent it one extra level so it sits inside the while True loop.
Indent a block quickly
Select all the lines of the for block, then press Tab to indent them all at once.
What does this change do?
The while True loop lets the model be called multiple times in a single conversation turn. Each iteration either ends with a break (when the model has no more tool calls and is ready to respond) or continues looping (when the model still needs to call tools).
The loop exits only when the model responds without any tool calls. That response is the final natural language answer.
Append tool results to the messages list
The loop can now re-call the model, but the model still has no way to see what the tools returned. You need to append two things after each round of tool calls: the assistant's own message (which contains its tool call requests) and each tool's result.
- Inside the while True loop, find the line that says break.
- Directly after the if not response.message.tool_calls: block (after the break), add this line to save the assistant's message into the conversation history:
messages.append(response.message)
Why append the assistant message?
The assistant message contains the tool call requests the model made. The Ollama API expects the conversation to include the assistant's tool call request before the tool results. Without it, the model has no context for what the results are answering.
- Now find the end of the for tool_call in response.message.tool_calls: loop, right after the result = f"Unknown tool: {tool_name}" line.
- Add this line at the end of the for loop (at the same indent level as the if tool_fn block) to send each tool's result back into the conversation:
messages.append({"role": "tool", "content": str(result), "tool_name": tool_name})
What does this message do?
Each tool result is appended as a message with role: "tool". This is the format the Ollama SDK expects for tool responses. The tool_name field tells the model which of its tool calls this result belongs to.
After the loop appends all tool results, it circles back to the top and calls chat again. The model now sees the full conversation including every tool result and can compose a natural language response.
- Save planner.py.
- Delete tasks.json if it exists from earlier testing, so you start with a clean slate. Run this in the terminal:
rm -f tasks.json
- Run the planner to confirm the loop is working by running:
python planner.py
- Type I need to exercise today and press Enter.
You should see [Calling add_task(...)] followed by a natural language sentence from the planner confirming your task. That conversational response is the proof the loop is working.
- Type quit to exit the planner.
Planner hangs or loops forever?
If the terminal freezes and never prints a response, press Ctrl+C to stop it. This usually means the messages.append lines are missing or misplaced. Without them, the model keeps getting the same messages and keeps requesting the same tool calls forever.
Check that messages.append(response.message) sits right after the break block, and that messages.append({"role": "tool", ...}) sits at the end of the for loop. Both must be inside the while True block.
Help me debug my agent loop in planner.py
✔️ Awesome, I've got everything!
Great. Double check you have saved planner.py.
ⓧ I'd like to double check the full code
The full planner.py file is long, so confirm each section is present and in order by scrolling through your file:
- Imports (json, datetime, Path, chat) and the TASKS_FILE constant at the top.
- Helper functions load_tasks and save_tasks.
- Tool functions: add_task, get_tasks, and get_all_tasks (unchanged from Step 4).
- The AVAILABLE_TOOLS dictionary mapping function names to functions.
- The updated run_agent function with a while True loop, messages.append(response.message) after the break block, and messages.append({"role": "tool", ...}) at the end of the for loop.
- The main function and if __name__ == "__main__": block at the bottom (unchanged from Step 4).
Test multi-turn interactions
The real test of an agent loop is handling complex requests. A single message like "I need to buy groceries today and I have a dentist appointment this month" should trigger two separate tool calls, and the model should compose one response covering both.
- Delete tasks.json again for a clean slate by running:
rm -f tasks.json
Before you run the planner, think about this: if you type a message that mentions two tasks with different timeframes, how many times do you expect to see [Calling add_task(...)] in the terminal?
- Start the planner by running:
python planner.py
- Type I need to buy groceries today and I have a dentist appointment this month and press Enter.
You should see [Calling add_task(...)] printed twice, once for groceries and once for the dentist appointment. After both tool results, the model composes a natural sentence confirming both tasks.
- Type Show me everything and press Enter.
The agent calls get_all_tasks and presents your full schedule in plain English, organized by timeframe and priority.
- Type Any tips for staying focused? and press Enter.
This time the agent responds directly without calling any tools. When no tools are needed, the model skips tool calls entirely and the loop exits on the first iteration.
- Type quit to exit.
What is the agent loop pattern?
What you just built is the same pattern used in production AI agents. The model receives a message, decides whether to call tools, executes them, feeds results back, and loops until it has everything it needs to respond. The model is in control of the decision-making, and your code just executes what it asks for.
This loop is also what gives your planner chatbot capability for free. When the model decides no tools are needed, it responds directly and the loop exits immediately.
Model not calling tools or giving odd responses?
Local LLMs can occasionally misinterpret a prompt. Try rephrasing your message to be more explicit, like "Add a task: buy groceries, due today, low priority, errands category." If the model consistently ignores tools, check that the tools parameter in the chat call lists all three functions: add_task, get_tasks, and get_all_tasks.
Help me troubleshoot my planner agent's tool calling
Your AI planner can now autonomously manage tasks, retrieve your schedule, and hold a conversation. You have built the full Ollama developer toolkit: raw chat, structured outputs, tool calling, and the agent loop. Next up, there is a Secret Mission waiting if you want to push your agent even further.
Secret mission
Add Reminders with Thinking Mode
Your planner manages tasks, but it cannot handle recurring reminders. And right now, you have no visibility into the model's reasoning. In this mission, you will add two new reminder tools and enable thinking mode so you can watch the model reason through each request before it acts.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs or subscriptions to worry about. Decide whether to keep your project files to experiment further or delete them to free up disk space.
Resources you used:
- ai-planner project folder on your Desktop (contains chat.py, parse_task.py, planner.py, tasks.json, reminders.json, and the Python virtual environment with installed packages).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your planner agent or extending it with new tools.
- Your ai-planner folder stays on your Desktop with all scripts and data files.
- Ollama remains installed on your machine with the qwen3:1.7b model (1.4GB). It runs as a background service and uses minimal resources when idle.
- The ollama and pydantic packages live inside the virtual environment in your project folder. They do not affect the rest of your system.
Pause - I'll come back to this later
Free up memory by stopping any running processes while keeping your project files intact for next time.
- Press Ctrl+C in the VS Code terminal to stop the planner if it is still running.
- Ollama runs as a background service and uses minimal resources when idle. No action is needed.
- Your project files, task data, and reminder data stay on your Desktop in the ai-planner folder, ready for when you come back.
Delete - I don't want to use this again
Remove the project folder and all its contents from your machine. This deletes your Python scripts, virtual environment, task data, and reminder data.
- Close VS Code.
- Open a new terminal window.
- Delete the ai-planner folder by running the command for your operating system:
macOS
rm -rf ~/Desktop/ai-planner
Windows
rmdir /s /q "$env:USERPROFILE\Desktop\ai-planner"
- Confirm the folder is gone by checking your Desktop. The ai-planner folder should no longer be there.
What about Ollama and the model?
Deleting the project folder does not remove Ollama or the qwen3:1.7b model (1.4GB) from your machine. Ollama stays installed as a background service.
If you want to remove the downloaded model or uninstall Ollama entirely, check the official Ollama documentation for removal instructions specific to your operating system.
Nice Work!
Nice Work!
You just built a fully working AI planner agent that runs entirely on your machine. No cloud APIs, no API keys, no data leaving your computer.
You've learned how to:
- Send messages to a local LLM using the Ollama Python SDK and force reliable structured JSON outputs with Pydantic.
- Created Python tool functions (add_task, get_tasks, get_all_tasks) and connected them to the model via Ollama's tool calling.
- Wired up a full agent loop where the model autonomously decides which tools to call, reads their results, composes natural language responses, and handles general conversation.
- Secret Mission: Added reminder tools and enabled thinking mode to see the model's chain-of-thought reasoning before it acts.
Ready to quiz yourself?