So far there has been one agent. Real applications split a job: one step finds the material, another reads it, another writes. In this chapter Scout becomes a pipeline of plain code and two agents.
The whole program
This is ch04_pipeline/scout.py. Run it with python ch04_pipeline/scout.py python.
"""Chapter 4: a two-step pipeline, plain code plus two agents.
Run it with: python ch04_pipeline/scout.py "python"
Step 1 (plain Python): find a story on Hacker News and fetch the page as Markdown.
Step 2 (agent "Reader"): summarise the page.
Step 3 (agent "Writer"): turn the summary into a short brief.
"""
import os
import sys
from maki import init_backend
from maki.agents import Agent
from maki.plugins.web_search import WebSearch
from maki.plugins.web_to_md import WebToMd
DEFAULT_SPEC = "ollama:qwen3.5:4b" # 3.4 GB. Bigger machine: "ollama:gemma4:26b".
def make_backend(spec=None):
"""The model Scout talks to, named by a "provider:model" string.
Set SCOUT_BACKEND to use another server, for example "llamacpp" or
"vllm:Qwen/Qwen2.5-1.5B-Instruct". Nothing else in the program changes.
"""
spec = spec or os.environ.get("SCOUT_BACKEND", DEFAULT_SPEC)
# think=False: qwen3.5 is a "thinking" model. Left on, a long page can use up its budget
# on reasoning and Maki then returns the reasoning as the answer. Ollama-only setting.
options = {"think": False} if spec.split(":")[0] == "ollama" else {}
return init_backend(spec, **options)
FOOTER = "\n\n_Made with Maki (https://pypi.org/project/maki-framework/)._"
MAX_PAGE_CHARS = 6000 # a small model reads a small page
def gather(topic):
"""Step 1: the first story that has a readable page, as (title, url, markdown)."""
for item in WebSearch().search_hackernews(topic, max_results=5):
page = WebToMd().fetch_and_convert_to_md(item["url"])
if page.get("success") and page.get("content"):
return item["title"], item["url"], page["content"][:MAX_PAGE_CHARS]
return None
def main(topic="python", backend=None, footer=True):
found = gather(topic)
if found is None:
print(f"No readable story found for {topic!r} this week.")
return None
title, url, markdown = found
model = backend or make_backend()
reader = Agent(name="Reader", maki_instance=model, role="reader",
instructions=("Summarise the page in five bullet points. Use only the page. "
"If the page is not about the title (a login wall, a cookie notice), "
"reply only: PAGE DOES NOT MATCH."))
writer = Agent(name="Writer", maki_instance=model, role="writer",
instructions="Write a three-sentence brief for a busy developer.")
summary = reader.execute_task(f"Page title: {title}\n\n{markdown}")
if "PAGE DOES NOT MATCH" in summary:
print(f"Skipped {url}: the page did not match its title.")
return None
brief = writer.execute_task(f"Source: {url}\n\nSummary:\n{summary}")
result = f"# {title}\n\n{brief}\n\nSource: {url}" + (FOOTER if footer else "")
print(result)
return result
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "python")
You should see a heading with the story's title, a three-sentence brief, the source link and a small "Made with Maki" line. It took about twelve seconds on the author's Mac.
Three steps, three owners
Step 1 is plain Python, not an agent. gather() calls the web_search plugin directly, then fetches each result's page as Markdown with the web_to_md plugin until one works. Finding a story and fetching a page need no judgement, so there is no reason to ask a model to do them. A step you can write as ordinary code is faster, free and always does the same thing. Use an agent only where you need language understanding.
Step 2 is the Reader agent. It gets the page text and writes five bullet points. MAX_PAGE_CHARS cuts the page to 6000 characters, because a small model reads a small page.
Step 3 is the Writer agent. It never sees the web page, only the Reader's bullets, and turns them into a three-sentence brief. Each agent has one job and one set of instructions, which makes each one easier to correct.
Two ways this pipeline broke, and the fixes
These are in the code because they happened.
The thinking trace. On the first real run, the answer was pages of the model's own reasoning. With thinking on, a long page used up the model's budget on reasoning, and when the reply came back empty Maki returned the reasoning instead. Setting think=False fixed it, and the run dropped from over two minutes to about twelve seconds.
The wrong page. One story's link led to a forum login page. The Reader dutifully summarised a login page, and the brief had nothing to do with the title. The fix is in the Reader's instructions: if the page is not about the title, reply PAGE DOES NOT MATCH. The code looks for those words and stops instead of passing nonsense to the Writer. A pipeline is only as good as the checks between its steps.
Check the output
A 4B model gets details wrong: names are misspelled, and it can state something the page does not say. That is why the source link is always at the bottom. Read the brief, open the link, compare. If it matters, use a larger model, and treat the brief as a draft.
One more thing worth knowing: the page text is untrusted. Neither agent here has any tools, so an instruction hidden in a page can change the words of the brief but cannot make Scout do anything. If you later give the Reader a tool, that stops being true, and chapter 3 explains why Maki keeps risky tool methods off by default.
Turn off the footer
The "Made with Maki" line is added by main(). To remove it, call main("python", footer=False).
What you built
A pipeline with a fetch step, two agents with separate jobs, and a check between them. From here you can change the source, for example to fetch_lobsters, add a third agent, or run the same code on a hosted model. If you want to see what else Maki can do, read the Maki page and the documentation.