The split:
Tools → actions the LLM executes (verbs)
LLM decides when to call; calls may have side effects
Examples: create_issue, update_status
Resources → data the LLM reads (nouns)
Host decides when to inject; read-only, no side effects
Examples: current Sprint status, project statistics
The rule: "reading a state" → Resource. "Executing an operation" → Tool. The same data can have both: get_issue
as a Tool (LLM controls when to call it), jira://issue/PROJ-101
as a Resource (Host injects automatically when relevant).
A static Resource returns the same data every time (like a project list). A dynamic Resource returns the current state on each read — content changes as the underlying data changes.
Sprint status: every read returns live data
_sprint_progress_pct = 65
@server.read_resource()
async def read_resource(uri: str) -> str:
if str(uri) == "jira://sprint/current":
global _sprint_progress_pct
_sprint_progress_pct = min(100, _sprint_progress_pct + random.randint(0, 3))
return json.dumps({
"sprint_name": "Sprint 42",
"progress_pct": _sprint_progress_pct, # ← different each time
"last_updated": datetime.now(timezone.utc).isoformat(), # ← timestamp changes
"days_remaining": 5,
"p0_open": count_p0_open(), # ← tracks live state
}, indent=2)
Test output:
Read 1: progress=65% last_updated=...62+00:00
Read 2: progress=67% last_updated=...04+00:00
→ ✓ data changed between reads
Hardcoding sprint progress in a Prompt means the LLM works from a stale snapshot. A Dynamic Resource gives it the current number on every read.
Mark the Resource as dynamic in its description so the LLM knows to re-read when it needs fresh data:
Resource(
uri="jira://sprint/current",
description=(
"Live status of the active sprint: progress, issue counts. "
"Read when the user asks about sprint health. "
"Re-read if you need up-to-date data — content changes over time."
),
)
When one Resource type has many instances, use parameterized URIs. list_resources()
enumerates all instances; read_resource()
uses a single handler for all of them.
One stats Resource per project:
@server.list_resources()
async def list_resources() -> list[Resource]:
resources = []
for key, proj in PROJECTS.items():
resources.append(Resource(
uri=f"jira://project/{key}/stats",
name=f"{proj['name']} Stats",
description=f"Issue statistics for {proj['name']} ({key}).",
))
return resources
@server.read_resource()
async def read_resource(uri: str) -> str:
if str(uri).startswith("jira://project/") and str(uri).endswith("/stats"):
proj_key = str(uri).split("/")[3].upper() # parse from jira://project/{key}/stats
if proj_key not in PROJECTS:
raise ValueError(f"Unknown project: {proj_key}")
proj_issues = [i for i in ISSUES.values() if i["project"] == proj_key]
return json.dumps({
"project": proj_key,
"total": len(proj_issues),
"by_status": count_by(proj_issues, "status"),
"by_priority": count_by(proj_issues, "priority"),
}, indent=2)
Test output:
jira://project/PROJ/stats → total=3, by_status={'Open': 2, 'In Progress': 1}
jira://project/MOBILE/stats → total=1, by_status={'Open': 1}
jira://project/INFRA/stats → total=1, by_status={'Done': 1}
The LLM reads only the project it needs. The Host can also inject the right Resource based on current context — if the user is working in the MOBILE project, inject MOBILE/stats
rather than dumping all projects at once.
URI design principles:
jira://project/{key}/stats ← hierarchical path (like REST)
jira://sprint/current ← active instance, no ID needed
jira://dashboard ← aggregate view, fixed URI
Avoid:
jira://stats_PROJ ← flat, doesn't scale
jira://data?project=PROJ ← query params, harder to parse
A Prompt template doesn't have to be static text. Render different sections based on argument values so one Prompt covers multiple scenarios cleanly.
Incident report: P0 includes Escalation section, P1 doesn't
if name == "incident_report":
severity = args.get("severity", "P1").upper()
workaround = args.get("workaround", "")
p0_section = ""
if severity == "P0":
p0_section = (
"\n## Escalation\n"
"- Engineering VP: notify within 30 minutes\n"
"- SLA breach risk: may breach the 4-hour P0 SLA\n"
)
workaround_section = ""
if workaround:
workaround_section = f"\n## Workaround\n{workaround}\n"
template = (
f"Create a formal incident report for {issue_key}...\n"
f"## Summary\n...\n"
f"## Root Cause\n..."
f"{p0_section}" # ← conditional insert
f"{workaround_section}" # ← conditional insert
)
Test output:
P0: escalation_section=✓ workaround_section=✗
P1: escalation_section=✗ workaround_section=✓
P0 incidents trigger escalation protocol; P1 incidents show the workaround. The LLM receives a different template and generates a structurally different report. No need for a single large template that the LLM has to interpret.
Standard Prompts have one user message. Multi-turn Prompts pre-fill a conversation history to guide the LLM through specific steps before producing the final output.
PR description: 3 turns, second turn is an assistant 'thinking' step
if name == "pr_description":
return GetPromptResult(
messages=[
PromptMessage(role="user", content=TextContent(type="text", text=(
f"You are a senior engineer writing a PR description.\n"
f"PR addresses: {issue_key}\n\n"
f"First, use get_issue to read the Jira issue details."
))),
PromptMessage(role="assistant", content=TextContent(type="text", text=(
"I'll fetch the issue details and then write a PR description "
"with: title, motivation, changes summary, test plan, and links."
))),
PromptMessage(role="user", content=TextContent(type="text", text=(
"Now write the complete PR description in Markdown."
))),
]
)
Test output:
Turn count: 3
Turn 1 (user): You are a senior engineer writing a PR description...
Turn 2 (assistant): I'll fetch the issue details and then write a PR description...
Turn 3 (user): Now write the complete PR description in Markdown.
Uses for multi-turn Prompts:
When a Prompt template embeds instructions to "read this Resource" or "call this Tool," the user doesn't need to supply data — the template tells the LLM how to get it.
if name == "standup_update":
return GetPromptResult(messages=[PromptMessage(role="user",
content=TextContent(type="text", text=(
f"Generate a daily standup update for {team_member}.\n\n"
f"Steps:\n"
f"1. Read jira://sprint/current to see overall sprint health\n" # ← Resource ref
f"2. Use search_issues to find issues {team_member} worked on\n" # ← Tool ref
f"3. Write standup: Yesterday / Today / Blockers / Sprint health"
))
)])
Test output:
References resource: ✓ (jira://sprint/current in template)
References tool: ✓ (search_issues in template)
The LLM receives this Prompt and automatically reads jira://sprint/current
, calls search_issues
, then generates the standup. The user only needs to say "generate my standup" — no manual data gathering required.
Resources and Tools aren't mutually exclusive. A Prompt can reference both:
LLM executing standup_update:
1. Read Resource jira://sprint/current → overall sprint health
2. Call Tool search_issues(query="closed", assignee="alice") → completed yesterday
3. Call Tool search_issues(query="open", assignee="alice") → planned today
4. Generate combine data into standup format
Resources inject background context (passive, no side effects). Tools execute queries or operations (active, LLM-initiated, may have side effects). Each type handles what it's designed for.
Resources
Prompts
Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.
Find more useful knowledge and interesting products on my Homepage