Open-source contribution
Hermes Discord Rich Presence
From prototype to upstream: how my own experiment became a privacy fix merged into someone else's plugin, a safer default it now ships, and a lifecycle fix proposed to Hermes itself.
- Hermes Agent's community Discord plugin, maintained by DarRahman, and Hermes Agent itself
- Contributor — fix merged, proposal adoptedDarRahman/hermes-discord-presence on GitHub (opens in a new tab)
- Author, open pull requestNousResearch/hermes-agent on GitHub (opens in a new tab)
- September 2026
The situation
I wanted Discord to show what my AI agent was doing — thinking, using a tool, which model — without giving away what I was working on. A status line looks harmless, but a session title alone can reveal a project. The real question was what an agent should reveal at all.
What I built
I built my own version first, which taught me where the hard parts were. Then I found that the community already had an established plugin. Instead of publishing a competitor, I used my version as a reference to check theirs: I reproduced every suspected problem, threw out the ones that did not hold up, and sent the maintainer a small, tested fix for the one that mattered most.
Unpaid open-source contribution. The Discord plugin is DarRahman's; my work there is one merged pull request and one proposal the maintainer adopted. I used coding agents throughout, and treated what they produced as a hypothesis until I had reproduced it.
The full story
I set out to show what my AI agent was doing in Discord. It turned into a question about what an agent should reveal, a discovery that someone had already built it, and a lesson in when to stop building my own.
Chapter 01
A status line that says too much
Discord can show a short line under your name about what you are doing. I wanted it to show my Hermes agent at work: which model, roughly how many tokens, whether it was thinking or running a tool, and for how long.
But that line is public to everyone in your servers, and a session title alone can give away what you are working on. So the question was never just how to put text in Discord. It was how to show an AI agent's activity without revealing more than the person using it meant to.
Chapter 02
Building my own, to learn the problem
I started with my own implementation. It never became a product. Its value was that it made me solve the hard parts myself, which later let me judge someone else's version on evidence rather than taste.
Show states, not secrets
Working, using tools, delegating, idle — generic labels by default, no session titles or models.
Never slow the agent down
The agent's events only update a small in-memory record; talking to Discord happens elsewhere.
Survive Discord coming and going
Reconnect with strict time limits, so a stuck Discord can't hang anything.
One voice across profiles
Several Hermes profiles can run at once; exactly one of them speaks to Discord.
It speaks Discord's local IPC protocol directly with Python's standard library — handshake, SET_ACTIVITY, clear, ping and close — with a total deadline on every exchange and a bounded frame length. Hook callbacks only touch locked in-memory sets keyed by session and turn; a background worker publishes. State precedence is fixed: Delegating › Using tools › Working › Idle › cleared. An OS file lock elects a single Discord writer across profiles, and a lease expires stale state if a process dies.
Building it also exposed a lifecycle gap in Hermes itself — see chapter 9.
Chapter 03
Someone had already built it
Then I found that Hermes already had an established community plugin for exactly this, maintained by DarRahman (opens in a new tab).
The easy move was to publish mine anyway because I had already built it. The useful one was to ask: can anything I learned make theirs better?
A second, near-identical plugin splits users and effort. So I stopped treating my version as something to ship and started using it as a measuring stick.
Chapter 04
Audit, don't assume
Comparing the two surfaced a list of suspicions. A difference from my design is not automatically a bug, so I tried to reproduce each one — and treated a suspicion that failed to reproduce as a result worth keeping, not an embarrassment to hide.
- Confirmed · fixed upstream
Privacy settings leak the activity label
With a privacy toggle on, the tool name still reached Discord through the tooltip.
- Reproduced · design question
Presence updates block the agent
Discord calls ran inside the agent's own event handlers, so a slow Discord could hold up its work.
- Reproduced · design question
Presence comes back after a session ends
A background thread reconnected and re-published after the session had finalized.
- Observation · feature gap
Idle is never shown
The Idle state could not be reached in normal use — a missing feature, not a fault.
- Did not reproduce · discarded
Reconnects leak file handles
Suspected from reading the code. Repeated reconnects did not grow open handles, so I dropped it.
Only the first became an upstream fix. The blocking and re-publishing behaviour reproduced, but they are architectural choices the maintainer is entitled to make, so I did not file them as bugs.
Chapter 05
The privacy bug, and the fix that was merged
The plugin had two privacy settings, and both looked like they worked: turn one on, and the activity disappeared from the main lines. But Discord shows more than those lines. The plugin still wrote the activity — say, “Running Terminal Command” — into the tooltip you see when you hover the app icon. Pick a setting below to see it.
Documented as: app name and elapsed time only.
- App
- Hermes Agent
- not shown
- not shown
- Hermes Agent — Running Terminal Command — leaked despite the privacy setting
A privacy setting is on, but the tool name still leaks.
- App
- Hermes Agent
- not shown
- not shown
- Hermes Agent
Nothing here says what you are working on.
A tool is running in both. The model is a placeholder. With no privacy setting on, the fix changes nothing.
The tooltip (Discord's large_text) was built unconditionally from the current status:
large_text = f"{self.large_text_template} — {self.current_status}"The fix applies the same rule the details line already followed. A neutral lifecycle word such as “Thinking” is not a tool name, so it stays, keeping hide_tool_status scoped exactly as documented:
status_names_tool = self.current_status not in LIFECYCLE_STATUSES
show_status = not stealth and not (hide_tool and status_names_tool)No configuration, dependency or payload-shape changes; the default configuration is byte-identical.
Chapter 06
Proving it, not hoping
A two-line fix is easy to write and easy to get subtly wrong. Before sending it, I made the bug fail a test, made the test pass, and then checked that nothing else moved.
01Reproduce the leak
Capture the real payload with each setting on
02Write tests that fail
Five new cases, failing on the original code
03Make the smallest fix
The tooltip follows the existing rule
04Run the whole suite
33 passing, plus a compile check
05Compare every combination
32 settings × statuses; default output identical
06Check the real thing
Real Discord on macOS, not a mock
07Submit a focused PR
Two files: the fix and its tests
08Merged upstream
Shipped in v1.2.2
- 01PR #6 submittedTwo files: the fix and its tests. (opens in a new tab)
- 02Merged upstreamBy the maintainer, the same day. (opens in a new tab)
- 03Shipped in v1.2.2The maintainer's release notes credit the privacy fix to PR #6 by @marcxxv. (opens in a new tab)
- 04Added to Authors & ContributorsListed in the plugin's README by the maintainer. (opens in a new tab)
v1.2.2 also carries the maintainer's own dependency fix; the privacy fix is the part that is mine. Being listed as a contributor is credit for that work, not a role in running the project.
View merged pull request #6 (opens in a new tab)Two of the new cases pass on the original code too, on purpose: they pin the unchanged default output and the documented scope of hide_tool_status, so an over-broad fix would fail. Also run: python -m py_compile, hermes plugins validate (ok, no warnings) and hermes plugins doctor (manifest, import and all five hooks registered). The real-client check used a throwaway Hermes home so no existing configuration was touched.
Chapter 07
Changing the default, not just fixing a leak
Fixing the tooltip closed a leak, but it left a bigger question. The plugin could already hide the session title — it just didn't, unless you went looking for the setting. Out of the box, it published the one thing most likely to say what you were working on.
So I opened an issue rather than a patch: a question about defaults is the maintainer's to decide. I proposed that new installs show a generic “Active Session” instead, and that model, tokens and tool state stay visible, because they are what make the status useful. The maintainer agreed, went further than I asked — an unset value now falls back to generic too — and shipped it.
Try it
- App
- Hermes Agent
- [Running Terminal Command] Project analysis — reveals the session title
- model • 105k tokens
- Hermes Agent — Running Terminal Command
Your session title is visible to everyone in your servers.
Out of the box, v1.2.0 published the session title; v1.2.3 shows “Active Session”. Model, tokens and tool state stay visible either way — they are what make the status useful.
The proposal is mine; the code change is the maintainer's. Read the issue and the maintainer's reply (opens in a new tab).
privacy.session_title_mode already accepted three values. Only the shipped default changed:
full Session: <title> · [Running Tool] <title>
generic Active Session · [Running Tool]
hidden (omitted) · (omitted)v1.2.3 sets generic in both config.yaml and the code fallback, so a config that omits the key or predates it fails closed. A new test asserts the fallback; the suite stands at 34 passing.
Chapter 08
A second bug — and why I didn't open a PR
Installing the plugin on my Mac, Hermes refused it with a dependency conflict. The surprise was the cause: even on macOS, the installer checks every platform the dependencies might ever run on, including Android. On that path, Hermes and the plugin disagreed about one library.
I reproduced it and found a workaround. But before opening a pull request, I checked upstream — and the maintainer had already fixed it, more cleanly, by removing the plugin's redundant requirement. So the right contribution was no contribution.
01Plugin rejected on install
02Reproduce and find the cause
A conflict only on an Android + Python 3.14 path
03Local workaround
04Search upstream first
05Already fixed in the maintainer's PR #7
06No duplicate PR
The maintainer's fix, not mine
uv resolves one lock file for every environment, so it also evaluated Android with Python 3.14. There, Hermes core pins psutil to a different source than the plugin's own psutil>=5.9.0,<8, and plugin admission failed. PR #7 — authored and merged by DarRahman — removed the plugin's psutil requirement and relies on the one Hermes already provides.
Chapter 09
A gap in Hermes itself
Separately from the plugin, building my own version exposed a rule Hermes did not always keep. Plugins are told when an AI turn starts, and expect to be told when it ends. On a few unusual exit paths — an early return, an error, an interruption — the end was never announced.
Discord presence mostly recovers on its own, because it refreshes periodically. The observers that really need a balanced start and end are others — cleanup and metrics — which is why I proposed this as a general fix to Hermes rather than a Discord workaround.
- Turn starts
- Agent works
- Unusual early exit
- End never announced
- Turn starts
- Agent works
- Any exit path
- End always announced
The rule: if Hermes announces a start, every way out must eventually announce the end — once, never twice. This pull request is open as of 27 September 2026.
A small ContextVar-scoped helper records each observed pre_llm_call and, if run_conversation returns or raises with that start still pending, emits a fallback on_session_end carrying the same identifiers. Normal finalization marks itself first, so there is never a duplicate. Concurrent turns on different threads or asyncio tasks each keep their own record.
51 tests pass across 9 files. The tests were also checked by breaking the fix on purpose: disabling the start record fails 6 cases, and removing the finalizer's mark fails exactly the one duplicate case.
Chapter 10
What I actually contributed
Five items on GitHub are part of this story. Three are mine: a merged fix, an adopted proposal and an open pull request. Two are the maintainer's, and are labelled that way.
- MergedPR #6ContributedPrivacy toggles now hide the activity label from every field Discord shows, including the tooltip. Merged by the maintainer and released in v1.2.2.View pull request on GitHub: fix(privacy): stop leaking the activity label through the large-text line (opens in a new tab)
- AdoptedIssue #8Proposed — implemented by the maintainerNew installs now show “Active Session” instead of the real session title. The maintainer agreed, implemented it and shipped it in v1.2.3.View issue on GitHub: Propose session_title_mode: "generic" as the default for new installations (opens in a new tab)
- OpenPR #124310ContributedMakes every announced turn start end with a turn end, even on early exits. Open for review.View pull request on GitHub: fix(agent): balance turn-end observer hooks on early exits (opens in a new tab)
- MergedPR #7Maintainer's fix — reproduced, not submittedThe install conflict I reproduced was already solved by the maintainer's own PR. I did not open a duplicate.View pull request on GitHub: fix: remove redundant psutil dependency and bump to v1.2.1 (opens in a new tab)
- OpenPR #124834Maintainer's PR — carries my fix and proposalPoints the official Hermes plugin catalog at v1.2.3, which includes my fix and my proposal. Opened by the maintainer; open for review.View pull request on GitHub: catalog: bump hermes-discord-rpc to v1.2.3 (opens in a new tab)
Chapter 11
How I worked
I use coding agents heavily — Claude Code, OpenCode, Codex. I treat what they produce as a hypothesis until the behaviour is reproduced. That mattered here: some early concerns held up and others did not, and the contribution came from that verification loop, not from accepting generated code at face value.
My part was the judgment around the code: defining what the behaviour should be, directing the investigation, challenging claims that had not been shown, deciding what was worth sending upstream — and whether it should be a patch or a question — and reviewing every diff and test before it left my machine.
Chapter 12
What I'd keep
- 01A different design is not a bug.Blocking calls and re-publishing reproduced, but they were choices, so I didn't file them.
- 02Try to disprove your own hypothesis.The file-handle leak looked plausible in the code and did not survive a test.
- 03Check privacy at the last step.Every setting looked right internally; the leak was in what finally reached Discord.
- 04Defaults are the real privacy policy.Most people never open the settings, so the default title mode mattered more than the option to change it.
- 05Search upstream before you patch.The install conflict was already fixed; a second PR would have cost the maintainer time.
- 06A small change upstream beats a new project.One fix and one proposal reached every user of the existing plugin.
- 07Integrations depend on balanced events.A start without an end is invisible until an observer relies on it.
Under the hood
The tools this is built with. You do not need to know any of them to use it — open one if you are curious what it does.