Documentation 30
Agent tutorial · Jira · 10 min
Test your agent against a frozen Jira.
An agent that reads Jira is only as testable as the data underneath it, and a real account gives you neither a fixed answer nor a safe place to be wrong. This page points a tool client at a pinned host whose bytes never change, runs one small agent task against it, and asserts the answer, so the run in CI tonight is the run you are doing now.
01 / Why a frozen replica
Three things an agent test needs.
- Determinism.
jira-v3-g12is a frozen snapshot, content addressed atc27f762cc1d2. Every byte it serves today is a byte it served yesterday, so an assertion on a reply count or a commit SHA is a test rather than a flake. - One coherent story. These rows are one simulated company,
olympus-labs, rendered into each vendor's dialect. The same incident is a thread on the Slack host,FATE-51on the Jira host and pull request 53 on the GitHub host, which is what lets an agent that crosses tools be judged on whether it crossed them correctly. - Nothing to babysit. No vendor sandbox to provision, no seed script, no token to rotate, nothing to clean up between runs. Every host is read-only, so the worst an over-eager tool call can do is produce the response in section 06.
02 / The host
One request, no key, no account.
The base URL is https://jira-v3-g12.snap.sandboxapis.dev. It serves Jira's own paths, JSON and headers, so the client you already use needs one line changed and nothing else. Send this before you write any code:
curl -s "https://jira-v3-g12.snap.sandboxapis.dev/rest/api/3/issue/FATE-51?fields=summary,status" \
| python3 -c "import json,sys; d = json.load(sys.stdin); print(d['key'], '|', d['fields']['summary'], '|', d['fields']['status']['name'])"FATE-51 | Postmortem follow-up: events dropped during a rebalance | DoneAnonymous callers get 60 req/hour, shared across every sandbox host rather than one bucket per host, which is enough to work through this page. A free key raises that to 600 req/hour and rides Jira's own auth slot, so an eval loop does not run out halfway. Keys and rate limits.
03 / The agent task
How long did the incident take to close, and did it beat the team's 72 hour target?
Save this as jira_agent.py and run it. It is plain requests, in the three beats an agent runs in: fetch the rows a tool would return, decide something from them, then check the decision. The middle beat is a pure function on purpose, so you can replace it with a model call and leave the assertions exactly as they are.
"""Agent task: how long did the incident take to close, and did it beat target?
Every request goes to a frozen SandboxAPIs snapshot host, so this file is a
test: the answer is the same today, in CI tonight, and in a year.
"""
import datetime
import requests
JIRA = "https://jira-v3-g12.snap.sandboxapis.dev" # the only line that changes
KEY = "FATE-51"
http = requests.Session()
http.auth = ("you@example.com", "any-token") # any pair is accepted
def get(path):
res = http.get(JIRA + path, timeout=30)
res.raise_for_status()
return res.json()
# 1. FETCH. The same two calls your agent's Jira tools already make.
issue = get("/rest/api/3/issue/%s?fields=summary,status,issuetype,assignee,created,resolutiondate" % KEY)
project = get("/rest/api/3/project/FATE")
fields = issue["fields"]
def at(stamp):
return datetime.datetime.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S")
# 2. REASON. The agent step: a pure function of the fetched data, so the same
# input always produces the same verdict. Swap in a model call here and the
# assertions below become an eval:
# answer = json.loads(client.messages.create(model="claude-sonnet-4-5",
# max_tokens=256, messages=[{"role": "user", "content": prompt}]) ...)
def cycle_time(issue_fields, target_hours=72):
if issue_fields["resolutiondate"] is None:
return {"closed": False}
elapsed = at(issue_fields["resolutiondate"]) - at(issue_fields["created"])
hours = round(elapsed.total_seconds() / 3600, 1)
return {"closed": True, "hours": hours, "met_target": hours <= target_hours}
answer = cycle_time(fields)
print("%s %s -> %s" % (issue["key"], fields["summary"], answer))
# 3. ASSERT. Known facts of the olympus-labs data set, frozen in this snapshot.
assert project["key"] == "FATE" and project["name"] == "Fates", project["name"]
assert fields["summary"] == "Postmortem follow-up: events dropped during a rebalance", fields["summary"]
assert fields["issuetype"]["name"] == "Incident", fields["issuetype"]["name"]
assert fields["status"]["name"] == "Done", fields["status"]["name"]
assert fields["assignee"]["displayName"] == "Tiresias", fields["assignee"]["displayName"]
assert answer == {"closed": True, "hours": 44.5, "met_target": True}, answer
print("ok")
FATE-51 Postmortem follow-up: events dropped during a rebalance -> {'closed': True, 'hours': 44.5, 'met_target': True}
okThe commented line inside the reasoning step is where a real model call goes. Everything above it is tool output and everything below it is judgement, which is the split that makes an eval readable: when the assertion fails you know whether the tools returned the wrong rows or the model read the right ones wrongly.
04 / In CI
Five lines that drop into a suite.
The same claim, as a test the runner you already have will collect. No fixtures to check in, no recorded cassettes to refresh, no credential in the job's environment:
import requests
PIN = "https://jira-v3-g12.snap.sandboxapis.dev/rest/api/3"
def test_the_incident_closed_as_done():
fields = requests.get(PIN + "/issue/FATE-51?fields=status,resolutiondate", timeout=30).json()["fields"]
assert fields["status"]["name"] == "Done" and fields["resolutiondate"] == "2026-06-19T16:21:17.000+0000"
Because the host is pinned rather than live, this test has no expiry date. The live host at https://jira.sandboxapis.dev re-rolls its data daily, which is what you want for a demo and the one thing you must not point a test at. Versioning and pinning covers the registry and how to choose a pin.
05 / MCP instead of HTTP
Same universe, through tools.
If your agent prefers tools to raw HTTP, @sandboxapis/mcp serves the same rows through orient, the read tools and check_budget. One environment variable points it at the pin instead of the live host, which makes the tool results as reproducible as the request above. Drop this into Claude Desktop's claude_desktop_config.json or Cursor's mcp.json:
{
"mcpServers": {
"sandboxapis": {
"command": "npx",
"args": ["-y", "@sandboxapis/mcp"],
"env": {
"SANDBOXAPIS_BASE_URL_JIRA": "https://jira-v3-g12.snap.sandboxapis.dev"
}
}
}
}The variable is SANDBOXAPIS_BASE_URL_JIRA, and every provider has one, so an agent can be pinned on the surfaces a test cares about and left live everywhere else. MCP setup has the full tool list and the one-line install for Claude Code.
06 / When the agent writes
The refusal is legible, not a crash.
An agent under test will eventually try to change something. Here is what that costs, captured from this host rather than described:
curl -s -i -X POST https://jira-v3-g12.snap.sandboxapis.dev/rest/api/3/issue \
-H "Content-Type: application/json" \
-d '{"fields":{"project":{"key":"FATE"},"summary":"opened by an agent under test"}}'{"errorMessages":["This universe is read-only. See what's covered at https://sandboxapis.dev/roadmap. (You tried to POST /rest/api/3/issue.) Tell us what you needed to write: https://sandboxapis.dev/feedback"],"errors":{}}It is Jira's own error envelope, so your client raises the exception it would normally raise, and the message names the exact request that was refused. The response also carries a header a harness can branch on, which is how a read-only refusal is told apart from a coverage 404 or a rate limit without parsing prose:
x-sandboxapis-read-only: trueNothing is mutated, and the next run reads exactly the same rows.
Next
Where to go from here.
The other two tutorials read the same incident from the other side: Frozen GitHub and Frozen Slack.
All agent tutorials · SandboxAPIs for agents · Verified quickstarts · Jira quickstart · Versioning and pinning · Coverage manifest