A chatbot answers. An agent acts. That one-word difference is the whole story of agentic AI, and it’s easy to miss because both are powered by the same large language model. The chatbot says “here’s how you’d create that Jira ticket.” The agent creates the ticket.
I’ve been building a series of small, practical agents to make that difference concrete. Two of them are worth walking through, because together they show the entire pattern end to end:
- A Jira user-story agent — you describe a feature in plain English, and it writes a properly structured story (summary, “As a… I want… so that…”, acceptance criteria, story points, the right epic and label) straight into your backlog.
- A GitLab MR agent — it creates a branch, commits files, opens a merge request, then reads the diff and posts an AI code review back onto the MR. Four real actions, chained, from one instruction.
Both run on a local model through Ollama — no cloud LLM bill, nothing leaves the machine — and both are built on the same small LangChain skeleton. Once you see that skeleton, agents stop being magic.
An agent is three things
Strip away the hype and an agent is just three parts working in a loop:
- A model that can decide to call functions (tool calling).
- Tools — ordinary functions the model is allowed to run.
- A loop that lets the model call a tool, see the result, and decide what to do next.
That’s it. Let me show each one from the real code.
1. The model
from langchain_ollama import ChatOllama
llm = ChatOllama(model="qwen3.5:9b", temperature=0)
llm_with_tools = llm.bind_tools(tools)
Two lines of intent. ChatOllama points at a model running locally via Ollama — so it’s free to run and private by default. temperature=0 keeps it deterministic, which you want when it’s taking real actions. bind_tools is the important one: it tells the model what functions exist and how to call them. After this, the model can reply with either text or a structured request to run a tool.
2. The tools
This is where LangChain is genuinely elegant. You write a normal Python function, decorate it with @tool, and the docstring becomes the model’s instruction manual for that capability:
from langchain_core.tools import tool
@tool
def create_jira_story(summary: str, description: str, epic_key: str = "",
story_points: int = 3, labels: str = "") -> str:
"""Creates a new Story in Jira from a plain-English description.
Input:
summary — the story title (short, action-oriented)
description — user story + numbered acceptance criteria
epic_key — parent epic to link to (e.g. 'SCRUM-7')
story_points — 1=trivial 2=small 3=medium 5=large 8=very large
labels — comma-separated (e.g. 'agent-core,backend')
Output: the new issue key and a direct URL to view it.
"""
...
The model never sees the function body — it reads that docstring to decide when to call the tool and what to pass. So the docstring isn’t documentation you write for other humans; it’s a prompt you write for the model. Vague docstring, confused agent. This is the single most underrated skill in building agents.
The tool itself is plain code. The Jira agent talks to the REST API with nothing but Python’s standard urllib — no SDK, no heavy dependency:
def _jira_post(path, payload):
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body, headers={...}, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
There’s a real-world wrinkle hiding in that tool, and it’s the kind of thing tutorials skip. Jira’s API doesn’t accept a plain-text description — it demands Atlassian Document Format, a structured JSON tree. And the LLM, left alone, writes Markdown (## headings, **bold**) that Jira rejects. So the tool quietly strips the Markdown and rebuilds the text as ADF paragraphs before sending. Agents spend a lot of their code on exactly this: translating between how a model likes to talk and how an API insists on being spoken to.
3. The loop
Here’s the part people imagine is complicated. It isn’t:
def ai_agent_interaction(user_input):
messages.append(HumanMessage(content=user_input))
for _ in range(15): # safety cap on iterations
response = llm_with_tools.invoke(messages)
messages.append(response)
if not response.tool_calls: # no tool wanted → we're done
break
for tc in response.tool_calls:
result = tools_by_name[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=str(result),
tool_call_id=tc["id"]))
return messages[-1].content
Read it slowly and the whole idea lands. Send the conversation to the model. If it asks for a tool, run the tool, append the result to the conversation, and send the whole thing back. Repeat until the model stops asking for tools and just answers. The range(15) is a guard rail — without a cap, a confused agent can loop forever, which is a real failure mode worth respecting.
Everything else — the eight Jira tools, the ten GitLab tools — plugs into this same loop unchanged.
The trick that makes them feel smart: tools that return instructions
Here’s a pattern I didn’t expect to love as much as I do. A tool doesn’t have to return data. It can return a prompt that tells the agent what to do next.
The Jira agent has a create_multiple_stories tool. You’d assume it loops and creates stories. It doesn’t — it hands the work back to the model:
@tool
def create_multiple_stories(feature_description, epic_key="", project_key="SCRUM"):
"""Creates multiple related stories from one feature description."""
return (
f"Feature to decompose: '{feature_description}'\n"
f"Please break this into 3-5 individual user stories, then call "
f"create_jira_story for each one. Each should be independently "
f"deliverable, in 'As a [user] I want [goal] so that [reason]' format."
)
The tool returns an instruction; the loop feeds it back to the model; the model then calls create_jira_story three or four times on its own. You’ve used a tool to steer the agent’s own reasoning. The GitLab agent does the same with review_merge_request — that tool just gathers the diff and metadata, then returns a structured “write a review covering bugs, security, missing tests, verdict…” instruction, and the model writes the actual review. The tool fetches; the model thinks.
Chaining real actions: the GitLab agent
The Jira agent mostly does one thing per request. The GitLab agent is where “agentic” earns its name, because a single instruction —
“create a branch, commit this file, open an MR for SCRUM-10, and review it”
— makes it run a four-step workflow autonomously:
create_branch→ makesfeature/SCRUM-10-…offmainadd_commit_push→ commits the files via the API (it even checks whether each file exists to decide create-vs-update, the API equivalent ofgit add && commit && push)open_merge_request→ opens the MR, and links the Jira ticket in the descriptionreview_merge_request→ pulls the diff, the model writes the review,post_mr_commentposts it back to GitLab
No step is hardcoded into a script. The model decides the order from the system prompt’s described workflow and the results coming back from each tool. That’s the difference between automation and an agent: automation follows a fixed script; an agent is handed tools and a goal and works out the sequence.
And because the MR description carries the Jira key, the two agents form one connected loop: describe a feature → Jira story exists → branch, code, MR, review → linked back to the story. A one-person version of a whole team’s ceremony.
Why local, and why it matters
Both agents run on a model served by Ollama on my own machine. That’s a deliberate choice, not a limitation:
- No per-token bill. An agent loop can make many model calls per task. On a paid API that adds up fast; locally it’s free.
- Privacy. Your code diffs and ticket contents never leave your machine — which matters a lot when the agent is reading proprietary source.
- It’s enough. A capable open model with good tool-calling support handles this work well at
temperature=0.
This is the same philosophy behind everything I build: use open tools well, and you get most of the capability at a fraction of the cost and none of the lock-in.
The honest caveats
Agents that act deserve more caution than chatbots that talk, so a few things I’d flag:
- Tool-calling quality is everything. The whole pattern depends on the model reliably producing well-formed tool calls. Smaller local models vary; pick one with solid tool-calling support and test it.
- Guard the loop. That
range(15)cap matters. So doestemperature=0. An agent with real write access that goes off the rails is worse than a chatbot that gives a wrong answer. - Write access needs guardrails. These agents create tickets and push commits. In anything beyond a personal project you’d want confirmations, a dry-run mode, scoped tokens, and a human approving before an MR merges. Capability and caution scale together.
- Context limits are real. The GitLab agent truncates diffs to ~4,000 characters so they fit the model’s context. A huge MR gets a partial review — useful, but know its edges.
None of these are reasons not to build agents. They’re the difference between a demo and something you’d actually trust near your workflow.
The takeaway
Agentic AI isn’t a new kind of model. It’s the same model, given hands (tools) and a loop. Once that clicks, the recipe is small enough to hold in your head: bind some well-documented tools to a model, run the call-tool-feed-back-result loop, and let tools steer the agent when a task needs decomposing.
These two are Part 6 and Part 7 of a build-along series I’m writing. The full, runnable code is on my GitLab, and I walk through it on my YouTube channel — links are on the about page. If you build one of these and it does something useful (or hilariously wrong), I’d love to hear about it.