Skip to content

Jira Operating Loop: What We Actually Automated

Jira operating loop

Jira Operating Loop: What We Actually Automated

Part six of our series on running an AI agent as a real teammate: the Jira operating loop we actually automated, documented so it can be rebuilt. Earlier parts: onboarding the agentits first taskassigning work to humanssupporting a Sprint Retrospective, and building a structured Jira plan.

Describing a workflow is not the same as making it reproducible. A description explains the design. An implementation record shows the artifacts: the helper interface, the query, the scheduled job, the task format, the failure modes, and the test that proves the whole thing works before it runs unattended.

This post is the implementation record. It documents the Jira operating loop as we ran it, including the parts that were weak, and it distinguishes between the helper we used in production and the safer reconstruction published alongside this article.

The system was not an autonomous Sprint planner. It was a constrained Jira operating loop: Sophie checked a defined queue every fifteen minutes, processed work assigned to her account, created Sprint issues from an explicit specification, and stopped when the request was ambiguous.

The operating contract

The agent was given a narrow operational contract:

  • work only on issues assigned to Sophie Hermes;
  • do not assign unassigned work to itself;
  • write Jira comments in English;
  • move clear work through the configured workflow;
  • analyse complex or ambiguous work and wait for human input;
  • use the Jira helper for Jira operations rather than improvising raw API calls;
  • skip issues that were already completed or had already received a substantive Sophie comment.

The last rule is important. A fifteen-minute polling loop is useful only if it does not repeat the same work indefinitely.

This contract did not give Sophie authority over Sprint priorities. Humans still supplied the Sprint specification and remained responsible for scope, assignments, and decisions that required team context.

The operational prompt

The contract above was implemented through the following prompt in the scheduled job. This is the prompt used during the period described in this post, reproduced with credentials and private identifiers removed:

Jira task checker for Sophie Hermes. Runs every 15 min.
ALL communication in ENGLISH.

USE THIS HELPER SCRIPT for all Jira operations (no inline curl/python):
  python3 ~/.hermes/scripts/jira_helper.py tasks
  python3 ~/.hermes/scripts/jira_helper.py issue KEY
  python3 ~/.hermes/scripts/jira_helper.py comment KEY "text"
  python3 ~/.hermes/scripts/jira_helper.py transition KEY "Status"
  python3 ~/.hermes/scripts/jira_helper.py transitions KEY

WORKFLOW:
1. Run: python3 ~/.hermes/scripts/jira_helper.py tasks
2. For each task found (skip if already Done or previously processed):
   a. SIMPLE task -> resolve automatically:
      - Transition to "In Progress"
      - Do the work
      - Add a comment with the solution
      - Transition to "Done"
   b. COMPLEX or ambiguous task -> analyze and comment only:
      - Add an English analysis and proposed approach
      - State that human approval is needed
      - Do NOT resolve
   c. DEADLINE in task -> create a one-shot cron job
3. If no tasks -> silent exit. Do nothing.

CRITICAL RULES:
- ONLY Sophie Hermes' tasks.
- ALL comments in ENGLISH.
- Skip tasks already processed or containing a substantive Sophie comment.
- Do NOT assign unassigned tasks to yourself.

This prompt is historical evidence, not a universal template. The direct Done transition reflects the configuration used during that period. Later workflow policy introduced Review as a human handoff state, so a rebuild must choose and document its current transition policy explicitly.

Prerequisites

The implementation ran on macOS with Hermes Agent and Jira Cloud. The agent account needed access to the Jira project and board, permission to read the relevant issues, add comments, transition issues, and create or edit issues where the workflow required it.

The credential file used by the original helper was:

~/.hermes/jira-creds.env

The original local format was:

<Classic Jira API token on the first non-empty line>
JIRA_URL=https://your-site.atlassian.net
JIRA_EMAIL=agent@example.com
JIRA_PROJECT=TYD

The variable names in Part 1 used a more conventional form: JIRA_URL, JIRA_USERNAME, and JIRA_API_TOKEN. A reader should not silently mix the two formats. The companion reconstruction published with this post accepts the clearer format below and also accepts the original bare-token format for compatibility:

JIRA_URL=https://your-site.atlassian.net
JIRA_EMAIL=agent@example.com
JIRA_API_TOKEN=<Classic-token>
JIRA_BOARD_ID=2

The token must stay in the local credential file. It should never appear in a prompt, source repository, log line, URL, or published example.

MCP and the local helper are alternative Jira paths

Part 1 introduces Hermes’ Atlassian MCP route. The scheduled Jira workflow documented here used a different path: a local Python helper called by the cron prompt. The helper was the active Jira path for this build because it exposed a small, explicit interface for reading issues, posting comments, and applying transitions.

The two paths should not be treated as cumulative requirements. A reader following Part 1 should choose one implementation and verify which tools the scheduled job actually calls. At the time this article was prepared, no Atlassian MCP server was configured for the active Hermes profile, and the Jira cron used jira_helper.py rather than MCP tools.

The helper script

The operational prompt instructed the agent to use these commands:

python3 ~/.hermes/scripts/jira_helper.py tasks
python3 ~/.hermes/scripts/jira_helper.py issue KEY
python3 ~/.hermes/scripts/jira_helper.py comment KEY "text"
python3 ~/.hermes/scripts/jira_helper.py transition KEY "Status"
python3 ~/.hermes/scripts/jira_helper.py transitions KEY

The real production helper is a small Python script. It loads a token from ~/.hermes/jira-creds.env, uses HTTP Basic authentication with the Jira email and token, calls the Jira Cloud REST API, and prints JSON for the agent to inspect.

The real helper also contains a few weaknesses that are important to document:

  • it hardcodes the Jira base URL and board ID;
  • its tasks JQL is status!=Done ORDER BY created DESC, so the query itself is not scoped to Sophie;
  • it retrieves up to 20 board issues and then filters them in Python by the display name Sophie Hermes;
  • its write functions report HTTP success but do not automatically read the issue back to verify the resulting state;
  • its legacy search function uses the deprecated /rest/api/3/search endpoint and should not be used for a new rebuild;
  • its task loop relies on the agent to inspect comments and status rather than on a separate durable processed-state database.

Those are not reasons to hide the script. They are reasons to publish it honestly.

The published reconstruction

The companion file for this post is published as a separate, versioned code artifact:

blog-6-jira-helper-reconstruction.py

Download the reconstructed helper

The file is published with a .txt extension so that it is served as plain text rather than as an executable script. Download it, read it, and rename it to jira_helper.py before use.

It is a reconstruction, not a verbatim copy of the production helper. It keeps the five-command interface and the same basic API operations, but it adds three protections:

  • it reads the base URL and credential names from the environment file instead of hardcoding the site;
  • it scopes the task query to currentUser() at the Jira layer;
  • after a comment or transition reports success, it fetches the issue again and verifies the expected result.

The reconstruction deliberately does not print credentials or request bodies containing secrets. A reader should publish this file alongside the article or embed it as an appendix, with the word reconstruction kept next to its title.

The authentication pattern

The essential authentication pattern is:

base_url, email, token = load_credentials()
response = requests.request(
    method,
    f"{base_url}{path}",
    auth=(email, token),
    timeout=20,
    **kwargs,
)

The requests library constructs the HTTP Basic Authorization header. The token is read at runtime and is never put in a URL.

Two implementation details in the reconstruction are worth calling out, because each one corrects something the original did loosely.

Comments are built and compared as Atlassian Document Format. The v3 API will not accept a plain string body. The helper wraps text into ADF on the way out and flattens ADF back to text on the way in, preserving block boundaries so a read-back comparison is meaningful. A rebuild that posts a plain string to v3 will receive a rejection that does not obviously explain itself.

Every write is verified, and a verification failure has its own exit code. A comment is confirmed by finding its ID on a re-read. A transition first checks that the requested target is among the transitions Jira is currently offering, refuses up front if it is not, and re-reads the status afterwards to confirm the move landed. Verification failures exit with code 4, distinct from an API error, because they mean a write may have partially applied and the loop must not simply retry.

What happens when Jira lies by omission

A successful HTTP status is not enough. Jira can accept a request while the resulting relationship, status, or comment is not what the automation intended.

The reconstruction therefore treats these cases as failures:

  • a comment POST returns success but the comment ID is not visible after a read-back;
  • a transition POST returns success but the issue status does not match the requested status after a read-back;
  • Jira accepts a write but returns no issue/comment identifier;
  • the requested transition is not among the transitions currently offered by Jira.

The response should stop with an error and require inspection. It should not continue as if the write were correct. Because these exit with code 4 rather than a generic API error, a supervising loop can distinguish a call that never landed from a write that may have partially applied, and treat only the former as safe to retry.

The original helper did not implement all of this verification. That gap is part of the build history and one of the first changes we would make in a clean rebuild.

The query that drives the loop

The real helper used the Jira Agile board endpoint:

GET /rest/agile/1.0/board/{boardId}/issue

with:

jql=status!=Done ORDER BY created DESC
maxResults=20
fields=summary,status,description,issuetype,priority,created,assignee

The clauses mean:

  • status!=Done: do not return issues whose status name is exactly Done;
  • ORDER BY created DESC: inspect the newest issues first;
  • maxResults=20: limit the board response to twenty issues;
  • fields=…: request only the fields the agent needs for triage.

The production helper then filtered the result in Python:

assignee = issue["fields"].get("assignee", {}).get("displayName", "")
if assignee == "Sophie Hermes":
    include(issue)

That is the actual mechanism, and it is weaker than putting the assignment constraint in JQL. A more reproducible rebuild should use:

project = TYD
AND assignee = currentUser()
AND statusCategory != Done
ORDER BY created DESC

The exact query should be adapted to the reader’s project, workflow, and account. Do not copy TYD, board 2, or the name Sophie Hermes as universal values.

How duplicate processing is avoided

There is no separate processed-state file in the production loop.

The effective state lives in Jira itself:

  • an issue in Done is excluded by the task query;
  • before acting on an open issue, the agent retrieves the issue and its comments;
  • if Sophie has already posted a substantive analysis, solution, or handoff comment, the agent skips the issue;
  • if the issue is ambiguous, the analysis comment becomes the record that it was already processed;
  • if the issue is completed, the status becomes the second deduplication signal.

There is no fixed machine-readable marker such as processed=true. The practical marker is a Sophie-authored comment plus the workflow status.

That is useful but fragile. It means:

  • if the comment succeeds and the process crashes before the transition, the next run should see the comment and skip the issue;
  • if the transition succeeds but the comment fails, a Done issue should still be excluded, but the audit trail is incomplete;
  • if a human moves an issue out of Done, the old comment can still cause the agent to skip it;
  • if the comment is deleted, deduplication may no longer work;
  • if the host is rebuilt, there is no local state to restore, because the state is in Jira comments and status;
  • there is no formal force-reprocess command in the original loop.

A human can request reprocessing by adding a new, explicit instruction, but the original implementation has no dedicated override marker. A safer future design would use a machine-readable Jira label or a comment marker with an explicit reprocess command and a clear audit trail.

This is exactly the kind of limitation that should be published. “We skip issues with Sophie’s prior substantive comment” is more accurate than pretending there is a durable job database when there is not.

Creating and scheduling the job

The scheduled job is stored in Hermes cron. The real job is named:

Jira: check Sophie's tasks (15min)

Its schedule is:

every 15m

The CLI form for a new job is:

hermes cron create "every 15m" \
  --name "Jira: check Sophie's tasks (15min)" \
  --deliver local \
  'Jira task checker for Sophie Hermes. Use the Jira helper, process only
   Sophie-assigned tasks, skip completed or previously processed issues,
   and exit silently when there is no work.'

In practice, the prompt should be stored in a carefully quoted file or passed through the Hermes interface rather than maintained as an unreadable shell line. The job definition is persisted under the Hermes state directory; the human-readable local record is:

~/.hermes/cron/jobs.json

The job can be inspected with:

hermes cron list
hermes cron status
hermes cron runs <JOB_ID>

The gateway must be running for scheduled jobs to fire:

hermes gateway status

Execution logs are available in:

~/.hermes/logs/agent.log
~/.hermes/cron/output/<JOB_ID>/

A job that is enabled and scheduled is not proof that it ran successfully. Check the last run, the exit status, and, when a Jira write was expected, the Jira issue itself.

Deadline reminders and one-shot jobs

The task loop also permits a deadline instruction to create a one-shot cron job. The CLI shape is:

hermes cron create "2026-08-07T17:05:00-05:00" \
  --name "Deadline: <short description>" \
  --repeat 1 \
  'At the scheduled time, inspect the specified Jira issue and follow the
   exact instruction. Report the result in English.'

A pending job is visible through:

hermes cron list
hermes cron status

A host that is asleep or has a stopped gateway is not a reliable execution environment. The scheduler may not fire at the intended moment, and a one-shot job should not be treated as delivered merely because it was created. After waking the host, inspect the job history and either run the work manually or create a new one-shot job with a clear buffer.

Field discovery: do not copy our custom IDs blindly

Jira custom fields are instance-specific. In our project, story points were stored in customfield_10016 and Sprint assignment in customfield_10020. customfield_10016 is common enough that another reader may find the same ID and assume it is universal. It is not. Verify it in the target instance.

A practical discovery sequence is:

0. Discover the board, Sprint, and account IDs

The creation payload needs three identifiers that cannot be inferred safely from names.

The board ID is the rapidView value in a Jira board URL such as:

https://your-site.atlassian.net/secure/RapidBoard.jspa?rapidView=2

In that example:

JIRA_BOARD_ID=2

The board ID is not the project ID and not the Sprint ID.

To discover the authenticated Jira account’s accountId, query the user endpoint:

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  --get "$JIRA_URL/rest/api/3/user/search" \
  --data-urlencode "query=$JIRA_EMAIL"

Find the matching user and record its accountId. Do not send the email address as if it were the account ID. Jira privacy settings may limit which user fields are returned, so verify the match before using it in an assignment payload.

To discover Sprint IDs for a board:

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  "$JIRA_URL/rest/agile/1.0/board/$JIRA_BOARD_ID/sprint\
?state=active,future&maxResults=50"

For historical Sprints, include closed Sprints and paginate when necessary:

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  "$JIRA_URL/rest/agile/1.0/board/$JIRA_BOARD_ID/sprint\
?state=active,closed,future&maxResults=50"

Match the Sprint by name and dates, then use its numeric id. For example, 5190 in customfield_10020 is an instance-specific Sprint ID, not a value readers should copy.

1. Read a known issue

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  "$JIRA_URL/rest/api/3/issue/TYD-XXXX?fields=summary,issuetype,\
assignee,parent,customfield_10016,customfield_10020"

Look for the field that contains the known story-point value and the field containing the Sprint object or Sprint identifier.

2. Retrieve project creation metadata

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  "$JIRA_URL/rest/api/3/issue/createmeta?projectKeys=TYD\
&expand=projects.issuetypes.fields"

Look for the project-specific issue types and the fields allowed when creating them. Global issue-type IDs are not guaranteed to be valid in the project.

3. Discover the story-point field

Inspect the create metadata and a known issue. Search the response for a field whose name is “Story point estimate” or the equivalent configured by the instance. Record both its ID and its schema type.

4. Discover the Sprint field

Inspect a known issue already assigned to a Sprint. Find the custom field containing the Sprint value. In our project that field was customfield_10020, but a new instance must confirm its own mapping.

5. List the board’s Epics

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  "$JIRA_URL/rest/agile/1.0/board/$JIRA_BOARD_ID/epic"

Use the returned Epic key, not only its summary. This avoids the parent failure described later in this post.

6. List available transitions

curl --fail --silent \
  --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
  "$JIRA_URL/rest/api/3/issue/TYD-XXXX/transitions"

Use the transition currently offered by Jira. Do not assume that a transition ID or status name is stable across workflows.

7. Create one test issue, then read it back

Create one harmless test issue with the minimum required fields. Immediately retrieve it again and verify:

  • project;
  • issue type;
  • summary;
  • assignee;
  • parent Epic;
  • story points;
  • Sprint membership;
  • status.

Only after that read-back matches the intended payload should the workflow create a batch.

The Sprint-task specification

The task-creation workflow became concrete when a human supplied a structured list in Jira for the next Sprint. The production task had initially been too ambiguous: it asked Sophie to insert or generate Sprint tasks without supplying the task list, Sprint target, or capacity assumptions. Sophie correctly stopped and asked for clarification.

A later comment supplied the missing specification. The redacted structure looked like this:

Task nameStory pointsAssignee
Project A. Create homepage3Team member A
Cross-project buffer5Unassigned
AI. Analyse the previous Sprint3Sophie
AI. Insert the next Sprint tasks3Sophie
AI. Generate the next Sprint tasks3Sophie
AI. Create event email copy3Sophie
Project B. Write the next blog postTBDCarlos
Project B. Update the agent documentation1Carlos
Project C. Set up an email3Carlos
Project C. Send an email3Carlos
Task 4TBDCarlos
Task 5TBDCarlos
Project A. Check SEO5Team member B
Project A. Set up the article in WordPress3Team member B
Project A. Create the article image2Team member B

This is a redacted reconstruction of a real Jira specification, not a general recommendation to use vague placeholders. In the actual run, Sophie created fourteen of the fifteen specified issues and reported their keys, story points, and assignees in a follow-up comment. The fifteenth was not created, because its assignee was ambiguous and the agent stopped rather than guessing.

The creation payload

The required minimum for the real creation helper was a summary and issue type. Assignee, parent Epic, Sprint, and story points were optional parameters. That meant rows marked TBD could be created without an estimate, and placeholder summaries such as “Task 4” could pass through. This is a known weakness: a safer rebuild should reject missing summaries, flag missing estimates, and require a human decision when an assignment or Epic is ambiguous.

The creation payload for one issue was structurally equivalent to:

{
  "fields": {
    "project": {"key": "TYD"},
    "summary": "Project A. Create homepage",
    "issuetype": {"name": "Task"},
    "assignee": {"accountId": "<verified-account-id>"},
    "parent": {"key": "<verified-epic-key>"},
    "customfield_10020": 5190,
    "customfield_10016": 3
  }
}

Do not copy the project key, Sprint ID, field IDs, or account IDs. Discover them in the target instance first.

What changed at Sprint 150

The jump in the evidence table, from zero Sophie-created issues in Sprints 146 to 149 up to fourteen of fifteen in Sprint 150, is not a mysterious model improvement.

The intervention was a change in input quality and workflow shape. Instead of asking Sophie to infer a Sprint from an incomplete instruction, the team supplied an explicit table containing task names, story points, and assignees. Sophie then converted that table into Jira issues and reported the resulting keys.

That is the reusable lesson. The agent became useful when we gave it a structured specification. We did not solve Sprint planning by asking the model to guess better; we separated the human planning decision from the mechanical Jira creation step.

The parent/Epic failure

The most useful implementation failure involved Jira’s parent field.

We initially treated parent as if it could reference an ordinary task that sounded like a parent. For the relevant issue types in our project, parent expected an Epic.

The correction was to list the board’s Epics, match the intended Epic deliberately, and write the verified Epic key. If multiple Epics have similar names, the automation should stop and ask for a choice.

A readable label is not a reliable identifier. A Jira API response with HTTP success is not proof that the resulting project relationship is correct.

Smoke test: verify the rebuild before scheduling it

Run these tests in ascending order of risk:

  1. Agent response. Send a harmless one-shot prompt and confirm Hermes returns a response.
  2. Helper authentication. Run python3 jira_helper.py issue TYD-XXXX and confirm the expected issue is returned without printing credentials.
  3. Task query. Run python3 jira_helper.py tasks with one known Sophie-assigned test issue and confirm it appears.
  4. Manual check. Run the task-checking prompt once before creating a schedule.
  5. Comment write. Post a harmless test comment and confirm the helper’s read-back verification finds it.
  6. Transition write. Transition the test issue to the intended status and confirm the read-back status matches.
  7. Deduplication. Run the same manual check a second time. It must skip the already processed issue rather than comment or transition again.
  8. Schedule. Create the fifteen-minute job and confirm it appears in hermes cron list with the expected next run.
  9. Silent empty queue. Run the job when no Sophie-assigned issue requires work. The expected result is no Jira mutation and no user-facing message.

Step 7 is the test that catches the most dangerous failure: a loop that appears healthy but repeats the same work every fifteen minutes.

What we deliberately did not automate

We did not automate:

  • choosing Sprint priorities;
  • inventing missing task requirements;
  • assigning unassigned tasks to Sophie;
  • selecting an Epic when names were ambiguous;
  • treating a title as a substitute for event or business context;
  • declaring an output correct solely because the API returned success.

The point was not to make the agent appear independent. The point was to make an explicit team decision easier to execute and easier to audit.

Residual security risk: ticket text is untrusted input

Jira descriptions and comments are external input to the agent. They can contain accidental instructions, copied prompt-injection text, links, code, or requests that conflict with the operational contract.

The helper reduces the direct API surface, but it does not eliminate the risk. The agent still reads ticket text and has access to terminal-based tools. A safer deployment should:

  • treat issue text as data, not as a replacement system instruction;
  • keep credentials outside prompts and logs;
  • use a least-privilege Jira account;
  • restrict the helper’s supported operations;
  • verify every write by reading the result back;
  • require human review for ambiguous, external-facing, security-sensitive, or destructive work;
  • avoid logging full ticket bodies when they may contain secrets;
  • test comments containing instructions as adversarial input before enabling autonomous handling.

The residual risk is not zero. A ticket can still persuade an agent to attempt an unsafe action if the surrounding tool permissions are too broad. The correct response is layered control, not the assumption that a prompt alone is a security boundary.

The agent account’s actual permissions

The Jira account used by the workflow could browse the project, create and edit issues, add comments, transition issues, assign issues when instructed, and receive assigned work. It could not delete issues or administer the project.

The two denials mattered most: the agent could not destroy work or change project configuration. The realistic failure surface was therefore a wrong comment, field update, assignment, or transition, each visible in Jira and potentially reversible. Sprint-management capabilities were governed separately by Jira Software board permissions and were not inferred from the platform permission endpoint.

What we would build differently

We would make four changes from the beginning:

  1. Put the assignment constraint in JQL rather than filtering by display name after a broad board query.
  2. Add a machine-readable processed marker with an explicit force-reprocess mechanism.
  3. Verify every write by reading the issue back.
  4. Reject or hold rows with missing summaries, ambiguous assignments, missing required context, or unresolved Epic names.

The production loop was useful, but its weaknesses were visible once we wrote down what a reader would need to reproduce it. That is the point of this post: reproducibility includes the awkward parts.

This is the sixth post in our series. Next up, eight Sprints of results from running it with a real team.

More in this series: Part one, onboarding the agent · Part two, its first task in Jira · Part three, assigning work to humans · Part four, supporting a Sprint Retrospective · Part five, a structured Jira plan · Part seven, eight Sprints in

Need more help putting an AI agent to work on your team?

Book a quick consultation and ask Jeff directly.

Ask Jeff