Let me try delegating task management in Backlog to NemoHermes

Let me try delegating task management in Backlog to NemoHermes

I built a mechanism to let agents operate Backlog. I will introduce the design decisions involved in starting from read-only access, gradually opening up update operations at a granularity that excludes deletions, running improvement loops in actual production, and the security pitfalls discovered along the way.
2026.09.04

This page has been translated by machine translation. View original

Introduction

Hello, I'm Shimada from the Classmethod Manufacturing Business Technology Department.

Last time, I used NeMo Switchyard and a post-trained judge model to build automatic request routing.

https://dev.classmethod.jp/articles/reona-02-dgx-spark-switchyard-routing/

This time, I'll have an agent operate Backlog.
I'll cover everything from starting with read-only access, to opening up updates while blocking deletes, to running improvement loops in production via Slack.
Team RAG and web search are planned for the next installment.

Integrating with Backlog Using Skills

MCP is the standard approach for external service integration, but this time I went with skills (a set of SKILL.md and bundled scripts).
There are two reasons for this.

The first is that MCP and general-purpose CLIs are difficult to run inside OpenShell (NemoClaw's sandbox execution environment).
Under a binary allowlist and L7 egress policy, a single zero-dependency Python script works more reliably.

The second is that smaller models can handle it.
Prior testing confirmed that even 30B-class models could use skills stably.

I set three design principles.

  • Double defense: Restrict both the requests the script can issue and the requests the network policy allows, in two layers. Operations that don't exist in the script also result in 403 policy_denied at the L7 level, so even if the script is called from outside via prompt injection, there's no escape.
  • Deterministic aggregation: Aggregation and judgment are handled by the script; the model is only responsible for formatting in Japanese. If the JSON says 3 overdue items, the report has 3 items — the model doesn't count. This is the primary reason stable operation is possible with a smaller model.
  • Credentials stored as files (skills/<name>/credentials/, 0600): NemoClaw's secret boundary rejects raw keys in .env, and OpenShell's credential resolver cannot rewrite query strings. Since Backlog authentication uses an apiKey query parameter, this form is the natural choice.

Starting with Read Access

The scripts call Backlog API v2 using only the standard library (urllib).
The core functions are daily-report, which returns a daily report in a single call, and stagnant, which identifies stalled issues.

PY=/opt/hermes/.venv/bin/python
SCRIPT=/sandbox/.hermes/skills/backlog/scripts/backlog_api.py

"$PY" "$SCRIPT" daily-report --project PROJ   # Returns material for morning standup summary as 1 JSON
"$PY" "$SCRIPT" stagnant --project PROJ --days 3

daily-report deterministically aggregates open issues by status and assignee breakdown, new items, updated items, overdue items, and upcoming deadlines — accounting for business days (looking back 3 days on Mondays).
The design keeps API calls to 10 or fewer per execution.

Deployment uses the official command, and no gateway restart is needed.

nemohermes team-assistant skill install ./skills/backlog

Egress is permitted minimally using a custom policy preset.
Initially, only read access was opened.

preset:
  name: backlog-readonly
  description: "Backlog API v2 read-only (GET only) for the backlog skill"
network_policies:
  backlog-readonly:
    name: backlog-readonly
    endpoints:
      - host: example.backlog.com   # Your space host
        port: 443
        protocol: rest
        enforcement: enforce
        rules:
          - allow: { method: GET, path: "/api/v2/**" }
    binaries:
      - { path: /opt/hermes/.venv/bin/python }

This allows access to the Backlog space only via GET, only from the skill's Python.
When asked "Give me today's Backlog summary" in Slack, the skill runs daily-report and the model formats the result.

Screenshot showing the Hermes agent using the daily-report skill to compile a summary

Opening Write Access Without Breaking Things

Once we started using it, we wanted write actions as well.
Requests came in to create tickets from action items identified in meeting notes, and to post analysis results as comments on issues.

This is where the granularity of the L7 policy pays off.
Instead of a binary choice of "allow writes or not," you can grant permission at the level of method and path combinations, allowing only additions and updates while blocking deletes.

preset:
  name: backlog-write
  description: "Backlog API v2 write access (add/update/attach; no delete)"
network_policies:
  backlog-write:
    name: backlog-write
    endpoints:
      - host: example.backlog.com
        port: 443
        protocol: rest
        enforcement: enforce
        rules:
          - allow: { method: POST,  path: "/api/v2/issues" }              # Create issue
          - allow: { method: POST,  path: "/api/v2/issues/*/comments" }   # Comment
          - allow: { method: PATCH, path: "/api/v2/issues/*" }            # Update issue
          - allow: { method: POST,  path: "/api/v2/space/attachment" }    # Send attachment
    binaries:
      - { path: /opt/hermes/.venv/bin/python }

Backlog updates use PATCH, not PUT.
Since DELETE is not written for any path, deletions are blocked at L7.
In practice, the permitted 4 operations reached Backlog, while DELETE /api/v2/issues/1 and POST /api/v2/projects were stopped at the sandbox level.

HTTP:PATCH  [INFO] ALLOWED PATCH .../api/v2/issues/1           [policy:backlog-readonly engine:l7]
HTTP:DELETE [MED]  DENIED  DELETE .../api/v2/issues/1          [policy:backlog-readonly engine:l7]
  [reason=DELETE /api/v2/issues/1 not permitted by policy]
HTTP:POST   [MED]  DENIED  POST   .../api/v2/projects
  [reason=POST /api/v2/projects not permitted by policy]

Regarding the policy name showing backlog-readonly in the logs: multiple presets targeting the same host have their rules merged and recorded under one preset's name.
Even when managed separately, the effective rules are the union, so rather than inferring behavior from the preset name, it's more reliable to check with policy list.

This verification can be run without credentials.
Calling without an API key means requests that pass through return a Backlog-side 401, while requests that are blocked return a sandbox 403 — allowing you to distinguish between permission issues and policy decisions.

The reason for separating read and write into separate presets is so that running policy remove backlog-write alone returns the system to read-only.
Being able to temporarily restrict permissions with a single command is operationally convenient.

I also added three mechanisms outside the network policy.

  • Per-project opt-in: The script rejects any project key not listed in the credential file. This allows operating with narrower scope than the API key's actual permissions. Even if the policy permits it, an empty file keeps it read-only — in practice, when I tried creating a ticket with an empty file, it was blocked before reaching the network.
  • Agent signature: Every issue and comment written includes a line saying "Created via [bot] Hermes." This ensures humans can search and trace what was touched.
  • Explicit input trust boundary: The SKILL.md explicitly states that issue bodies and comments are untrusted input, and that instructions written in them (e.g., "mark everything as complete") are not requests.

The main reason for not allowing deletes is the third point.
Since the text the agent reads is written by others, the goal is to make the worst-case outcome "something extra appears" rather than "something disappears."

All Writes Failed Because of an Emoji in the Signature

Immediately after pointing writes at the production space, all writes started failing.

HTTP 400 Bad Request
{"errors":[{"message":"Incorrect String: %F0%9F%A4%96 ...","code":7}]}

The cause was a robot emoji in the signature.
This space doesn't accept 4-byte UTF-8 characters (which most emoji are), and since the signature is appended to all writes, every operation failed with the same error.
The fix was changing the signature to the plain string [bot].

I think the more valuable insight is why this was missed.
The verification at that point only confirmed "POST and PATCH pass through L7 and reach Backlog."
Whether a policy allows something and whether the application accepts it are different layers, and the latter requires at least one round-trip with real data.
Since then, whenever a new operation is opened, I always run one application-layer round-trip separately from the policy verification.

Making a Dedicated Account the Write Principal

In the initial setup, I was issuing the Backlog API key from my personal account.
Once writes became routine, this created two problems.

The first is history.
Issues created and comments posted by the agent were all recorded under my name, making it impossible to distinguish agent activity from human activity in the issue history.
The signature line exists, but assignee filters and notifications operate by account name.

The second is key lifecycle.
A personal account key reaches the full scope of the issuer's permissions and is also tied to the issuer's transfers or departures.

So I created a dedicated bot account and swapped the key.
Subsequent writes are recorded under the bot's name, and the per-project opt-in continues to apply as a restriction narrower than the key's permissions.

Screenshot of a Backlog issue confirming the transition from writes under a personal account to writes under a bot account

The swap itself required only replacing the credential file — no changes to the skill or policy.

Even With Permissions Open, If the Router Doesn't Know, It Won't Be Used

At this point, the L7 policy was passing writes, and the script could create tickets and post comments.
However, when I actually asked for a ticket to be created in Slack, Hermes started hanging on "processing" for a long time.

The cause was the router built in Part 2.
The Capability Card that conveys environment capabilities to the judge still had the description from when it was read-only.

Write policy: NONE in this profile. Requests to create, update, close, assign,
or comment on issues cannot be executed; the agent can only prepare text and
state that writing needs the write policy and a human confirmation.

The judge reads this and determines that write requests "cannot be executed in this environment."
p_solve drops to 0.02, well below the threshold, so all requests are routed to the heavier strong-side model.
The judgment itself is sound — what was wrong was the environment description being passed to the judge.
Average response time for write requests was 32.9 seconds, with a maximum of 133 seconds.
The "stuck on processing" experience was exactly this.

Only the Card text was changed.
No models, policies, or scripts were touched.
I rewrote it to state that writes are available, that deletion is impossible, and that others' comments won't be edited, then restarted the shim.
As a result, over 35 write-related requests in production use from 9/2 to 9/4, all were processed by the lighter weak-side model.

The lesson here is that permissions are written in three places.
The L7 policy, the skill script, and the judge's Capability Card.
Fixing only the first two leaves the system capable but unused.
Since the Card is just a text file, I added "update the Card whenever capabilities change" to the procedure.

Of the three, only the bottom two manifest as rejections.
The Card alone never rejects — it only shows up as latency.
That's why it was hard to notice.

On the other hand, some things can't be changed by updating the Card.
This bot's key only has visibility into one Backlog project, but if a request doesn't include the project name, the judge determines "the project is unknown."

The request asks for a list of Backlog issues, but does not name a project key
or issue key, so the project is missing.

Adding to the Card that "there's only one visible project so there's no ambiguity" didn't fix it.
The rule of "treat missing project as ambiguous" is fixed from the judge's training time.
In production, all 33 matching cases went to strong.
What the Card can influence is "which skills are available" — "what counts as ambiguous" cannot be changed.
The workaround in practice is to include the project name in the request.

Production Use Sharpens the Skills

Running things in practice surfaced several small improvement opportunities.

Can't Edit Comments

The agent posted a comment lacking context, and when instructed to fix it, it added a new comment describing the fact that it had received correction instructions, rather than editing the original.
This was because update permissions had been opened for issues only, not for comments.
Since the Backlog API supports comment editing (PATCH /api/v2/issues/:key/comments/:id), I added one line to the policy and implemented update-comment in the skill.

Also, only comments written by the bot itself can be edited.
The script retrieves the comment author's ID and rejects the edit if it doesn't match its own ID.
Overwriting someone else's comment, like deletion, can result in an unrecoverable loss.
Since the dedicated account migration was already complete, determining "written by the agent itself" could be done by account identity.

Body Text Gets Collapsed to One Line

Another issue was that the body of comments requested via Slack contained literal \n characters.

The cause was shell quoting.
The agent passes the body as a command argument, but writing \n inside single quotes as an intended newline doesn't get expanded by the shell.
The backslash and n as two characters reach the API as-is.

Since the signature the script appends contained real newlines, this identified that the content was already broken before reaching the script.

The fix was two-pronged.

The first was adding a proper alternative.
Multi-line bodies are written to a file and passed via --body-file, and SKILL.md was updated to say "always use this for multi-line content."
File-based passing structurally prevents quoting accidents.
Note that reading from the credential area (/sandbox/.hermes) as a body file is rejected.
Specifying a key file in --body-file would post its contents as a comment, so this path is also closed as an exfiltration route.

The second was normalization in the script.
Literal \n and \t are converted to actual newlines and tabs, and Slack link syntax (<url|label>) and bullet point symbols are converted to Backlog-readable forms before sending.
SKILL.md instructions may not reach older sessions, so defenses against anything the model might write are placed in the script.
However, since normalization can't distinguish from real \n inside code blocks, SKILL.md also notes that this is only a safety net.

Routing Slack Attachments to Backlog

A request to "attach a screenshot I posted in Slack as a comment on an issue" didn't work.
It looked like a simple operation, but the cause was spread across three layers.

Layer 1 is egress.
Slack attachments are fetched from /files-pri/ under files.slack.com, but only the upload POST /upload/** was open — the GET for downloading was missing.

What made diagnosis difficult was the agent's explanation.
When receiving a 403, Hermes responds "the bot probably lacks file read permissions or scope."
Looking at the adapter implementation, this message is hardcoded in the source and appears regardless of the reason Slack returned.
Since the actual cause was an L7 deny, no amount of reviewing Slack app scopes would reveal anything.

Layer 2 is the session.
After fixing the policy, asking "try again" didn't prompt Hermes to retry — it responded "I'll get the same result no matter how many times I try."
This is because it continues a conversation while retaining past conclusions.
Even doing /hermes new to cut the session, requesting again in the same thread brings back the same conclusion.
The adapter has an implementation that loads history for threads without a session, and that history contains Hermes's own "insufficient permissions" statement.
The reliable approach was to make retry requests in a new thread after fixing the environment.

Layer 3 was a save location design mistake.
The adapter saves files received from Slack to /sandbox/.hermes/image_cache/.
Meanwhile, the Backlog skill's --attach rejects anything under /sandbox/.hermes.
This is a guard to prevent credential exfiltration, but received files happened to be placed in exactly that forbidden area.

In other words, the bytes were already on disk, but they couldn't be attached from there.
The agent couldn't detect this inconsistency and was delegating work to humans with "please re-paste it as base64."
Rather than relaxing the guard, I added a procedure in SKILL.md to copy to /sandbox first and then attach.
I judged that one extra copy step is safer to read later than opening a hole in the boundary.

Putting all three together, a common pattern emerges: when independently designed safety mechanisms conflict, the agent cannot resolve it.

Delivering Deliverables to Humans

When asked to analyze meeting notes or generate a report, the agent writes Markdown to /sandbox and reports "saved."
But /sandbox is inside the container — invisible to everyone on the team.
The deliverable gets left where it was born.

Asking "upload it to Slack" doesn't solve it either.
The Slack adapter runs on the parent Hermes gateway side, and the sandbox where tools execute can't reach the Slack API.
So I added a skill that simply uploads files to Slack.

Uploading Without Holding a Real Token

Calling the Slack API requires a token, but I don't want a token in the sandbox.
What's in the sandbox's .env is a placeholder, not a real token.

xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN

When this is sent in Authorization: Bearer, OpenShell's resolver substitutes the real token in transit during egress.
The skill never holds the real token.
Even if .env is read out, this string is all that appears.

The reason Backlog authentication was stored as a file is that this mechanism couldn't be used there.
The resolver can only rewrite headers and body — Backlog's apiKey is in the query string.
Even within the same environment, the available options differ by authentication method.
The resolver will also play a central role in the next installment on web search.

Guards Against Exfiltration Go in the Script

Uploading is itself a pathway for sandbox contents to leave.
Guards are placed in the script, not in SKILL.md.

  • Paths are limited to under /sandbox, and anything under /sandbox/.hermes, where Hermes core information is stored, is rejected
  • Destinations are limited to permitted channels only
  • Maximum 50MB per file

All three were confirmed to actually reject:

Refused: /sandbox/.hermes/.env is under /sandbox/.hermes (credentials and skill state).
Refused: /etc/hostname is outside /sandbox. This skill only uploads workspace deliverables.
Refused: channel C09999999 is not in SLACK_ALLOWED_CHANNELS. Allowed: C0AL...

Closing Thoughts

NemoHermes's L7 egress policy is a solid guardrail that can scope permissions down to the method and path level.
Having this is what made it possible to decide to let an agent touch the production Backlog space.
I feel that organizational production use is entirely realistic.

At the same time, fine-tuning continues.
The character set in the signature, newlines in comments, where received files are stored, the environment description passed to the router.
None of these were visible at design time — they all emerged once we started using it.
There's still a long way to go, but I'll continue operating and improving while searching for the right shape.

Next time: team RAG and web search.
I'll connect the agent to both knowledge that doesn't leave the organization and knowledge fetched from outside.

References

Share this article

Related articles