Triage a repository's open issues with one agent per issue
A repository with hundreds of open issues has duplicates nobody has time to find. Give each issue
its own agent: 300 agents, one per issue, in one program. They talk only through a message board,
with two tools, send_swarm_message and read_message(after=, limit=). Each member posts a
one-line summary of its issue, then reads the whole board and flags the issue that reports the same
problem as its own. An editor agent reads the board and writes the triage report. You watch the
board fill up in a browser.

The board while 300 agents triaged the open issues of astral-sh/uv (sped up). The highlighted lines are the duplicates the members found.
Demonstrates: tools that reach your own service, session inputs that tell each tool which member calls it, a read-only hint, hundreds of sessions in one process, a timeout per round, and one call for the report.
Run: start the board, then, from the example's folder with MISTRAL_API_KEY set:
uv run python issue_triage.py astral-sh/uv 300
The members talk through the board of the QA swarm: a small web app on Postgres
that keeps each run on its own board. Run it on your machine or
deploy it on Mistral Apps, and set
SWARM_BOARD_URL to its URL.
Two tools for the members
send_swarm_message posts, read_message reads. A member's name comes from a session input, so the
model cannot post as another member:
class Member(agents.SessionInput):
"""Who this session is on the board."""
input_id = "swarm.member"
name: str
@agents.tool
async def send_swarm_message(text: str, me: Annotated[Member, agents.Depends()]) -> str:
"""Post a one-line message on the swarm's board, where every member can read it."""
response = await board.post("/messages", json={"author": me.name, "text": text})
response.raise_for_status()
return f"posted as message {response.json()['id']}"
@agents.tool(annotations=agents.ToolAnnotations(read_only_hint=True, destructive_hint=False))
async def read_message(after: int = 0, limit: int = 50) -> str:
"""Read the board's messages with an id greater than `after`, oldest first, at most `limit` (200 at most)."""
response = await board.get("/messages", params={"after": after, "limit": limit})
response.raise_for_status()
lines = [f"{m['id']} {m['author']}: {m['text']}" for m in response.json()]
return "\n".join(lines) or "(no newer messages)"board is an httpx.AsyncClient on the board's URL, SWARM_BOARD_URL. It sends your Mistral API
key in X-Api-Key, which a board on Mistral Apps checks and a board on your machine ignores.
The members and the editor
Every member is the same Agent, with both tools. The editor only reads the board, and writes its report as its answer:
triager = agents.Agent(
model="mistral-large-4",
name="issue-triager",
instructions=(
"You are one member of a swarm that triages a repository's open issues. Each member owns one "
"issue. The swarm talks only through its board: send_swarm_message posts, "
"read_message(after=, limit=) reads. Keep every message to one line."
),
tools=[send_swarm_message, read_message],
)
editor = agents.Agent(
model="mistral-large-4",
name="triage-editor",
instructions=(
"You write the triage report of a swarm that summarized a repository's open issues on its "
"board. Read the whole board with read_message, paging with after=. Then answer with the report "
"alone, in Markdown: the groups of duplicates, each with its issue numbers and the shared problem "
"in one line; then the five areas with the most issues."
),
tools=[read_message], # it reads the board, and writes only its answer
)The prompts name the tool to call. The board holds only what a member posts, never its answer:
SUMMARIZE = (
"Your issue is #{number}: {title}\n\n{body}\n\n"
"Call send_swarm_message once with '#{number} [<area>] <the symptom in at most 15 words>'. "
"Then answer 'posted'."
)
FIND_DUPLICATES = (
"Every member has posted. Read the whole board, paging with after= until there are no newer "
"messages. If another issue reports the same problem as yours, call send_swarm_message with "
"'#<yours> duplicates #<other>: <why, in 10 words>'. Otherwise post nothing. "
"Answer with the duplicate's number, or 'none'."
)Two rounds
The program starts one session per issue, then runs two rounds. Each round waits for every member,
so all the summaries are on the board before any member looks for its duplicate. A member that
looked earlier would read a half-empty board and miss it. With timeout=, a round waits three
minutes at most: a member still running then is left out, and its turn keeps running until the program
cancels every session at the end:
sessions = await asyncio.gather(
*(
triager.start_session(session_inputs={Member.input_id: Member(name=f"triager-{issue['number']}")})
for issue in issues
)
)
try:
# Two rounds, each gathered: every member has posted before any member looks for duplicates.
# A round waits ROUND_SECONDS at most: a member still running then is left out, so one slow
# turn never holds up the other 299.
summaries = await asyncio.gather(
*(
session.run(
SUMMARIZE.format(number=issue["number"], title=issue["title"], body=(issue["body"] or "")[:1500]),
timeout=ROUND_SECONDS,
)
for session, issue in zip(sessions, issues)
)
)
late = sum(isinstance(state, agents.Running) for state in summaries)
print(f"round 1 after {time.monotonic() - started:.0f} s: {len(issues) - late} members done, {late} left out")
answers = await asyncio.gather(*(session.run(FIND_DUPLICATES, timeout=ROUND_SECONDS) for session in sessions))
found = [state for state in answers if isinstance(state, agents.Completed) and "none" not in state.text.lower()]
print(f"round 2 after {time.monotonic() - started:.0f} s: {len(found)} members found a duplicate")
finally:
await asyncio.gather(*(session.cancel() for session in sessions))
match await editor.run("Write the triage report."):
case agents.Completed(text=report):
print(f"\n{report}")
case other:
print(f"the editor stopped: {other}")Run the swarm
From the example's folder:
uv sync
export MISTRAL_API_KEY=...
export GITHUB_TOKEN=... # optional: GitHub limits the requests it answers without one
uv run python issue_triage.py astral-sh/uv 300Open the URL the program prints to watch the board.
Output of a 300-issue run
requires-python when picking the tool version/interpreter (silent downgrade or confusing build error).platform_release marker range comparisons mis-evaluate on macOS 26, so resolution picks the wrong platform branch.uv.lock/sync despite PEP 592.--prompt value executes command substitution when the activate script is sourced.The whole run took 7 minutes on a laptop: 83 s for 300 summaries, three more minutes for the duplicates, then the editor's report. Each group in the report is a set of open issues for a maintainer to check and close as duplicates of one.
Adapt it
- Another repository: pass its
owner/nameand how many open issues to triage. - Another job for the swarm: change the two prompts and the editor's instructions. The board and the tools stay the same.
- More rounds: add a gathered round, for example one where each member reads the duplicates and confirms or rejects the one about its own issue.