By Deepti Reddy | April 2026
This is Part 3 of an 8-part series covering every multi-agent strategy in AgentSpan. Today, we will go over the handoff strategy — the LLM decides which agent handles the task.
In Part 1, we built a sequential pipeline: triage >> root_cause >> postmortem. Each agent’s output feeds the next. In Part 2, we built a parallel code review: three reviewers run simultaneously on the same code.
Both of those patterns have a fixed structure. You know upfront which agents run and in what order. But what about tasks where you don’t know the path until you see the input?
A GitHub issue comes in. It might be a bug. It might be a feature request. It might be a docs question. You don’t know until you read it and each type needs a completely different handler.
That is the handoff strategy. The LLM reads the input, decides which specialist should handle it, and hands off. One agent runs, not all of them.
What we are building
An issue triage bot with three specialist agents:
- Bug Handler : analyzes severity, affected component, repro steps
- Feature Handler : summarizes the request, estimates complexity
- Docs Handler : identifies the gap, drafts a reply
The triage agent reads the issue and picks one. Not a fixed pipeline. Not fan-out. The LLM decides the route.
Issue → [Triage] → reads it → decides → [Bug Handler]
or [Feature Handler]
or [Docs Handler]
Why not sequential or parallel?
Sequential (>>) runs every agent in order. But a feature request doesn’t need the bug handler. You would waste API calls running agents that have nothing to do.
Parallel runs every agent at the same time. But you don’t need three opinions on the same issue , you need the right one.
Handoff runs exactly one agent — the one the LLM picks. It’s a router that thinks, not a pipeline that grinds.
Setup
Same as Part 1 and Part 2:
pip install agentspan
agentspan server start
Defining the specialists
Three agents, each with tight instructions that prevent hallucination:
from agentspan.agents import Agent, AgentRuntime, Strategy
bug_handler = Agent(
name="bug_handler",
model="openai/gpt-4o",
instructions=(
"You handle bug reports. Read the issue CAREFULLY.\n\n"
"You MUST output EXACTLY this format and NOTHING else - no greeting, "
"no explanation, no sign-off, no extra text:\n\n"
"Severity: P0/P1/P2/P3\n"
"Component: <which part>\n"
"Repro steps: <what the user described, or 'Not provided'>\n"
"Labels: bug, <severity>\n"
"Engineering summary: <2–3 sentences>\n\n"
"Example output:\n"
"Severity: P2\n"
"Component: REST API - /users endpoint\n"
"Repro steps: Send a GET request with limit=0. Returns 500 instead of empty list.\n"
"Labels: bug, P2\n"
"Engineering summary: Off-by-one in pagination. The /users endpoint does not "
"handle limit=0. Affects v2.1+ only.\n\n"
"RULES:\n"
"- ONLY use information the user actually wrote. No guesses.\n"
"- Do NOT suggest workarounds or fixes.\n"
"- Do NOT add any text outside the format."
),
)
feature_handler = Agent(
name="feature_handler",
model="openai/gpt-4o",
instructions=(
"You handle feature requests. Read the issue CAREFULLY.\n\n"
"You MUST output EXACTLY this format and NOTHING else - no greeting, "
"no explanation, no sign-off, no extra text:\n\n"
"Request: <one sentence>\n"
"Use case: <in the user's words, or 'No use case provided'>\n"
"Complexity: small/medium/large\n"
"Labels: enhancement, <area>\n"
"Community summary: <2–3 sentences>\n\n"
"Example output:\n"
"Request: Add CSV export for agent execution history.\n"
"Use case: User wants to import execution data into their BI tool for "
"weekly reporting.\n"
"Complexity: small\n"
"Labels: enhancement, observability\n"
"Community summary: Request for CSV export of execution history. User "
"needs it for BI/reporting integration. Low complexity - the data is "
"already queryable.\n\n"
"RULES:\n"
"- ONLY use information the user actually wrote. No guesses.\n"
"- Do NOT promise timelines or delivery.\n"
"- Do NOT add any text outside the format."
),
)
docs_handler = Agent(
name="docs_handler",
model="openai/gpt-4o",
instructions=(
"You handle docs issues and questions. Read the issue CAREFULLY.\n\n"
"You MUST output EXACTLY this format and NOTHING else - no greeting, "
"no explanation, no sign-off, no extra text:\n\n"
"Confusion: <what the user is stuck on>\n"
"Doc gap: <which doc page is missing or unclear>\n"
"Draft reply: <under 50 words - acknowledge the gap and say the "
"team will update the docs>\n"
"Labels: documentation\n\n"
"Example output:\n"
"Confusion: User doesn't know how to configure retry behavior.\n"
"Doc gap: The tools page does not mention retry configuration.\n"
"Draft reply: Good catch - the docs don't cover this yet. "
"We'll add a section on retry configuration to the tools page.\n"
"Labels: documentation\n\n"
"RULES:\n"
"- ONLY describe the gap. Do NOT answer the technical question.\n"
"- NEVER write code examples - you don't have access to the "
"source code and will get it wrong.\n"
"- Keep Draft reply under 50 words. Just acknowledge and commit "
"to updating the docs.\n"
"- Do NOT add any text outside the format."
),
)
Same Agent class as Parts 1 and 2 and same pattern. The difference is what comes next.
The handoff strategy
triage = Agent(name="triage",
model="openai/gpt-4o",
agents=[bug_handler, feature_handler, docs_handler],
strategy=Strategy.HANDOFF,
instructions=(
"You are an issue triage bot. Your ONLY job is to route.\n\n"
"1. Read the issue.\n"
"2. Hand off to exactly ONE agent:\n"
" - Error/crash/traceback/regression → bug_handler\n"
" - Feature request/suggestion → feature_handler\n"
" - Docs question/confusion → docs_handler\n"
"3. After the specialist responds, output their response "
"VERBATIM. Copy-paste it exactly. Add nothing.\n\n"
"You are a router, not an analyst. Do NOT add your own words."
),
)
The triage agent has access to three sub-agents. It reads the input, picks one, and delegates. Compare to the other strategies:
Sequential — fixed order, all run
pipeline = bug_handler >> feature_handler >> docs_handler
Parallel — all run at once
team = Agent(agents=[bug_handler, feature_handler, docs_handler],
strategy=Strategy.PARALLEL)
Handoff — LLM picks one
triage = Agent(agents=[bug_handler, feature_handler, docs_handler],
strategy=Strategy.HANDOFF)
Same agents. Different strategy. Different behavior.
Running it
with AgentRuntime() as runtime:
result = runtime.run(
triage,
"File upload fails with a 500 error when the filename has spaces. "
"Uploading 'report.pdf' works, but 'Q1 report.pdf' returns a server "
"error. Looks like the filename isn't being URL-encoded.",
)
result.print_result()
Result → bug_handler
The triage agent reads it, sees “fails with a 500 error” and “works / doesn’t work.” Routes to bug_handler:
Severity: P1
Component: File Upload Service
Repro steps: Upload a file with spaces in the filename like
'Q1 report.pdf'. Returns a 500 error.
Labels: bug, P1
Engineering summary: The File Upload Service fails to handle
filenames with spaces, leading to a 500 server error. Likely
a URL encoding issue with filenames containing spaces.
No invented details. Repro steps are exactly what the user said. No workarounds suggested.
Now swap the input to a feature request:
result = runtime.run(triage,
"Would be great to have a drag-and-drop zone for file uploads "
"instead of the file picker button. Most modern apps support this.",
)
Routes to feature_handler. Swap it to a docs question:
result = runtime.run(
triage,
"The docs mention a max file size limit but don't say what it is "
"or how to change it. Where do I configure this?",
)
Routes to docs_handler. Same triage bot, same code. The LLM decides the path each time.
How durability works
Same as Parts 1 and 2 — the handoff compiles into a durable workflow on the server. If your process crashes after the triage agent picks bug_handler but before bug_handler finishes:
- The triage decision is persisted on the server.
- You restart your script.
- bug_handler resumes — no re-triage, no re-reading the issue.
This matters when you are triaging a backlog of 50 issues overnight. If the process dies at issue 37, it picks up at 37 — not back to 1.
Going further: connect to GitHub + Discord
The hardcoded issues were fine for learning Strategy.HANDOFF. But in production, you’d fetch real issues from GitHub and route notifications to Discord. Here’s how.
Store your credentials
In the AgentSpan dashboard (localhost:6767), go to Credentials and add:
Name: GITHUB_TOKEN
Value: Your GitHub personal access token (needs repo scope)
Name: DISCORD_TOKEN
Value: Discord bot token
Discord setup
- Go to discord.com/developers/applications → New Application
- Bot tab → Reset Token → copy it
- Turn on Message Content Intent
- OAuth2 → URL Generator → select bot scope → permissions: Send Messages, Read Message History, Add Reactions
- Open the generated URL to invite the bot to your server
- Create channels: #bugs, #feature-requests, #docs
- Copy each channel ID (right-click channel → Copy Channel ID)
The tools
from agentspan.agents import tool
import os
import requests
GITHUB_API = "https://api.github.com"
DISCORD_API = "https://discord.com/api/v10"
DISCORD_CHANNELS = {
"bugs": "YOUR_BUGS_CHANNEL_ID",
"feature-requests": "YOUR_FEATURES_CHANNEL_ID",
"docs": "YOUR_DOCS_CHANNEL_ID",
}
@tool(credentials=["GITHUB_TOKEN"])
def get_issue(repo: str, issue_number: int) -> dict:
"""Fetch a GitHub issue by number."""
token = os.environ["GITHUB_TOKEN"]
resp = requests.get(
f"{GITHUB_API}/repos/{repo}/issues/{issue_number}",
headers={"Authorization": f"Bearer {token}"},
)
issue = resp.json()
return {
"number": issue["number"],
"title": issue["title"],
"body": issue.get("body", ""),
"user": issue["user"]["login"],
"labels": [l["name"] for l in issue.get("labels", [])],
}
@tool(credentials=["GITHUB_TOKEN"])
def search_issues(repo: str, query: str) -> list:
"""Search for similar or duplicate issues."""
token = os.environ["GITHUB_TOKEN"]
resp = requests.get(
f"{GITHUB_API}/search/issues",
headers={"Authorization": f"Bearer {token}"},
params={"q": f"{query} repo:{repo}", "per_page": 5},
)
return [
{"number": i["number"], "title": i["title"], "state": i["state"]}
for i in resp.json().get("items", [])
]
@tool(credentials=["GITHUB_TOKEN"])
def add_labels(repo: str, issue_number: int, labels: list) -> dict:
"""Add labels to a GitHub issue."""
token = os.environ["GITHUB_TOKEN"]
requests.post(
f"{GITHUB_API}/repos/{repo}/issues/{issue_number}/labels",
headers={"Authorization": f"Bearer {token}"},
json={"labels": labels},
)
return {"status": "labeled", "labels": labels}
@tool(credentials=["GITHUB_TOKEN"])
def post_comment(repo: str, issue_number: int, body: str) -> dict:
"""Post a comment on a GitHub issue."""
token = os.environ["GITHUB_TOKEN"]
requests.post(
f"{GITHUB_API}/repos/{repo}/issues/{issue_number}/comments",
headers={"Authorization": f"Bearer {token}"},
json={"body": body},
)
return {"status": "commented", "issue_number": issue_number}
@tool(credentials=["DISCORD_TOKEN"])
def post_to_discord(channel_name: str, message: str) -> dict:
"""Post a message to a Discord channel. channel_name: bugs, feature-requests, or docs."""
token = os.environ["DISCORD_TOKEN"]
channel_id = DISCORD_CHANNELS.get(channel_name)
if not channel_id:
return {"status": "skipped", "reason": f"No channel configured for #{channel_name}"}
requests.post(
f"{DISCORD_API}/channels/{channel_id}/messages",
headers={"Authorization": f"Bot {token}"},
json={"content": message},
)
return {"status": "posted", "channel": channel_name}
Wire them in
The specialists gain tools — they label the issue, post a comment, and notify the right Discord channel. We also add a fetcher agent that pulls the issue from GitHub first, then hands off to the triage handoff:
fetcher = Agent(name="fetcher",
model="openai/gpt-4o",
instructions=(
"You fetch GitHub issues. Call get_issue with the repo and "
"issue_number from the prompt. Return the issue's title and "
"body verbatim."
),
tools=[get_issue],
)
bug_handler = Agent(
name="bug_handler",
model="openai/gpt-4o",
instructions=(
"You handle bug reports. Read the issue CAREFULLY.\n\n"
"Do these steps in order:\n"
"1. Search for duplicate issues.\n"
"2. Add labels: 'bug' + severity label.\n"
"3. Post an analysis comment on the GitHub issue.\n"
"4. Post a summary to the 'bugs' Discord channel.\n\n"
"Do NOT guess details the user didn't provide."
),
tools=[search_issues, add_labels, post_comment, post_to_discord],
)
feature_handler = Agent(
name="feature_handler",
model="openai/gpt-4o",
instructions=(
"You handle feature requests. Read the issue CAREFULLY.\n\n"
"Do these steps in order:\n"
"1. Search for duplicate requests.\n"
"2. Add labels: 'enhancement' + area label.\n"
"3. Post a comment acknowledging the request.\n"
"4. Post to 'feature-requests' Discord channel.\n\n"
"Do NOT promise timelines. Do NOT invent use cases."
),
tools=[search_issues, add_labels, post_comment, post_to_discord],
)
docs_handler = Agent(
name="docs_handler",
model="openai/gpt-4o",
instructions=(
"You handle docs issues. Read the issue CAREFULLY.\n\n"
"Do these steps in order:\n"
"1. Add the label 'documentation'.\n"
"2. Post a reply on the GitHub issue under 100 words.\n"
"3. Post to 'docs' Discord channel.\n\n"
"If you don't know the answer, say 'The team will follow up.'"
),
tools=[add_labels, post_comment, post_to_discord],
)
triage = Agent(
name="triage",
agents=[bug_handler, feature_handler, docs_handler],
strategy=Strategy.HANDOFF,
instructions=(
"Read the issue. Hand off to ONE agent:\n"
"- Error/crash/traceback → bug_handler\n"
"- Feature request/suggestion → feature_handler\n"
"- Docs question/confusion → docs_handler\n\n"
"Do NOT answer it yourself. Just route."
),
)
Sequential: fetcher pulls the issue, then triage routes it
pipeline = fetcher >> triage
Run it against a real FastAPI issue:
The fetcher agent has one job: call get_issue to pull the issue from GitHub and return its title and body. That is why we chain it sequentially before triage (fetcher >> triage) — so the fetched issue flows straight into the handoff. Without it, you wouldhave to paste the raw issue text into the prompt yourself, like the hardcoded example.
with AgentRuntime() as runtime:
result = runtime.run( pipeline, "Triage issue #13056 in repo fastapi/fastapi")
result.print_result()
The fetcher pulls the issue from GitHub. The triage agent reads it, hands off to the right specialist. The specialist labels the issue, posts a comment, and pings the right Discord channel. Full loop — GitHub in, GitHub + Discord out.
Try it
pip install agentspan
agentspan server start
python 03_issue_triage_handoff.py
# For the GitHub + Discord integration version:
# 1. Add GITHUB_TOKEN and DISCORD_TOKEN in the AgentSpan dashboard (Credentials page)
# 2. Update REPO, ISSUE_NUMBER, and DISCORD_CHANNELS in 03_issue_triage_github_discord.py
# 3. python 03_issue_triage_github_discord.py
- GitHub : github.com/agentspan/agentspan
- Blog examples : github.com/agentspan/agentspan/tree/main/sdk/python/examples/blog_and_videos/handoff
- Docs : agentspan.ai/docs
- Discord: https://discord.com/invite/ajcA66JcKq
What’s next
Part 4: The Router Strategy — Like handoff, but with a dedicated classifier agent that handles the routing decision. Separate the “who should handle this” from the “handle it” — cheaper, faster, more controllable. Same Agent class, different strategy.









Top comments (0)