The hard part of running an agent on X is not writing replies. It is teaching it not to.
I built @heyWebcmd, a public bot that takes website requests in the replies and builds command-line adapters for them. It runs on Hermes, an open-source agent framework. Getting it to answer people was a weekend. Getting it to shut up took longer.
This is what broke, in the order it broke.
TL;DR. X mentions are not the same as requests. I stopped unwanted replies with a prompt-level engagement gate and NO_REPLY, then split public conversation from login-gated adapter work across two Hermes profiles so the bot stays responsive and credentials stay isolated.How I built and tested this
Everything here comes from one bot in production, not a lab. @heyWebcmd runs on a Hermes gateway with the X platform plugin I wrote, against the real X API, replying to real strangers.
The engagement problems in this post are ones I watched happen on my own timeline, then fixed. Where I quote a number, it is my own measurement or a figure from the project's own docs. I have marked the one place I still consider unsolved.
Why does an X bot reply to things nobody asked it?
Because on X, being mentioned and being addressed are different things, and the API hands you both in the same stream.
My bot polls its mentions timeline. That sounds precise. It is not. Two kinds of posts arrive through that pipe:
- Someone actually asking the bot for something.
- Everything else with the handle in it.
The second bucket is bigger than I expected. It has two nasty members.
Promo posts. A launch tweet that says "ask @heyWebcmd to create a CLI for your website" mentions the bot without asking it anything. The bot read that as a summons and replied. To an ad. For itself.
Reply-chain noise. This one is structural. When someone replies inside a thread your bot is part of, X prepends the handles of everyone in the chain, including yours. So a message plainly aimed at another human still lands in your mentions wearing your name.
The result looked like this. User1 asks the bot a question. User2 replies to User1, saying something to User1. The bot answers both, because both arrived tagged.
Nobody in that thread asked the bot twice. It answered twice anyway.
{/* IMAGE NEEDED: /blog/images/x-bot-replied-to-promo-tweet.webp alt: "X bot replying to a promotional tweet that only mentioned its handle" caption: "The whole bug in one screenshot. That post was an ad, not a question." */}
How do you get an agent onto X in the first place?
You write a platform adapter: the piece that turns X's API into events your agent understands, and your agent's output back into posts.
Hermes speaks to Telegram, Slack, Discord, WhatsApp, Signal, and others through a shared adapter interface. X was missing. There had been an attempt, an open pull request (#12352) that had gone stale.
I did not merge it. The implementation plan I wrote for the rebuild carries this as a hard constraint:
Work from current main; salvage intent from PR #12352 without merging the old PR wholesale.
That distinction saved me. The old PR knew what to build and had already discovered the sharp edges of the X API. Its code had drifted a long way from the codebase it was supposed to plug into. Reading it as a design document and rewriting the code was faster than reconciling the diff.
If you inherit a stale PR, try that framing before you try git merge.
Which parts of a bot does Hermes hand you for free?
Most of the parts that are boring to write and painful to get right. The adapter was the only piece specific to X.
Everything below already existed, which is the actual argument for building on a framework instead of a bare API client.
| What a chat bot needs | What I wrote | What Hermes provided |
|---|---|---|
| Talk to the X API | The adapter, OAuth 2.0 with PKCE, polling, media | The interface it plugs into |
| Remember a conversation | Nothing | Per-conversation sessions keyed to the thread |
| Not answer twice at once | Nothing | Turn serialization per session |
| Choose silence | Nothing | A NO_REPLY token the gateway swallows |
| Long jobs without blocking | Nothing | Background tasks that re-enter the session |
| Run two bots at once | Nothing | Profiles, each with its own service, memory, and tokens |
That silence primitive matters more than it looks. A framework where "produce no output" is a first-class result lets you fix engagement in the prompt. One where every turn must produce a message forces you into code.
Why did the bot run locally instead of in the cloud?
Because the work behind the replies is browser work, and browsers are fussy about where they live.
@heyWebcmd is a front door for WebCMD. WebCMD watches an agent use a website once, then compiles what it learned into a deterministic CLI. The next agent spends its tokens on the task instead of re-learning the navigation. Triggering that pipeline is the bot's real job.
Running that from hosted infrastructure ran into residential proxy trouble. The hosted path is a documented alpha, with Kernel providing the browser infrastructure behind it. The docs are honest that the two environments do not share state:
Local and hosted profiles, adapters, site memory, and traces do not sync. Treat each mode as its own working environment.
So I ran the whole thing off a local Hermes install. Not elegant. It worked on launch day, which was the only requirement that mattered that week.
What broke on launch day?
The bot replied to the launch post. The one announcing the bot.
The campaign put the call to action in the tweet text and the first reply: reply with any website, most-liked gets its CLI built. That first reply mentioned the handle. The bot, doing exactly what it was told, treated our own marketing copy as a user request and answered it at the top of the thread everyone was about to read.
I deleted it by hand.
{/* IMAGE NEEDED: /blog/images/heywebcmd-launch-thread.webp alt: "The @heyWebcmd launch thread on X with the call-to-action reply" caption: "The launch post and its call-to-action reply. The handle in that reply is what set the bot off." */}
That is the moment the problem stopped being theoretical. A bot that cannot tell a request from a mention does not have a tuning issue. It has a design gap. Every fix after this one traces back to that deleted reply.
What Humalike taught me about the problem
It gave the problem a name: turn-taking. Until I saw someone else's feature list, I had been describing my bug in paragraphs.
Humalike ships a Hermes plugin that adds social intelligence to group chats, backed by its hosted APIs. Its feature list reads like a spec for everything I was stuck on, plus a lot I was not. Turn-taking that decides when to jump in versus stay quiet. Persona generation. Theory of mind. Social learning that picks up a group's slang.
Credit where it is due. Seeing "decides when to jump in vs stay silent" written as a product feature is what made me realize my problem had a shape, and that the shape was well known.
I did not adopt it. My case is one bot, one platform, one narrow judgment call. Humalike solves a superset across Slack, Telegram, WhatsApp, and Discord, and if I needed persona work or cross-platform social behavior I would look at it seriously. For "should I answer this specific tweet", a dependency and an API round trip felt heavy.
So the question became: how small can the in-house version be?
The fix was prose, not code
Smaller than I wanted it to be. I designed a real coalescing layer, then deleted it in favor of about forty lines in a prompt file.
Here was the plan I talked myself into. Buffer every incoming post per conversation. Start a ten-second timer, extend it when more posts arrive, cap it at thirty. When it fires, hand the agent one turn holding the whole burst, and let it emit targeted replies or nothing.
It is a decent design. I still think it is correct at high volume. I wrote none of it, because of two things already sitting in the framework.
Turns are serialized per conversation. When User2's post gets processed, the bot's reply to User1 is already in the history it can see. And NO_REPLY means "say nothing" is an outcome the agent can pick. Between those two, the agent had everything it needed to make the call itself. What it did not have was permission.
So I added a gate to the top of its SOUL.md, the file that defines who the bot is:
## Engagement gate (speak or stay silent)
Being mentioned is not the same as being addressed. Decide whether this
message is directed at you and expects a reply, then classify it:
1. Directed request - someone is asking you to do something, or replying
to one of your own posts to continue a request. Proceed.
2. Reference only - your handle appears but the post is not asking you
anything: it promotes, recommends, tags, or talks about you. Your
entire response is exactly `NO_REPLY`.
3. Overheard - a reply inside a thread you are part of, aimed at another
participant, not you. Treat it as context; respond `NO_REPLY`.
When unsure, prefer `NO_REPLY`. A missed reply is recoverable; an
unwanted public reply is not.
Four categories, one default. That last line is the whole safety model: on a public timeline, silence is cheap and a wrong reply is not.
The promo tweet stopped getting answered. The double replies stopped. My debounce buffer went in the bin, and I have not missed it.
{/* IMAGE NEEDED: /blog/images/x-bot-thread-single-reply.webp alt: "X thread where the bot answers one user and stays silent on the follow-up reply" caption: "Same thread shape as before. One question, one answer, silence on the rest." */}
Note. This works because turns are serialized and silence is a real output. On a platform where messages arrive in parallel bursts, or a framework with no silence token, you need the buffer. Check your primitives before copying the shortcut.
Why did login walls force a second agent?
Because some adapters cannot be built without a human signing in, and a bot that stops to wait for you is a bot that looks broken in public.
The flow works fine while sites are open. Someone asks for a website, the agent explores it, compiles an adapter, opens a pull request. Then someone asks for a site behind a login.
An agent cannot get past that alone, and it should not try. Somebody has to sign in. That turns a fully automatic job into one that pauses in the middle for a human, and the pause can last hours.
Putting that pause inside the bot that talks to strangers is the wrong place for it. So I split the work across two Hermes profiles, each running as its own service with its own memory and credentials, passing work between them on a Kanban board.
The public profile answers what it can and files a card for what it cannot. The builder profile picks up the card, pauses for a human when a site needs credentials, and opens a PR. Completion comes back to the original X thread.
{/* IMAGE NEEDED: /blog/images/hermes-kanban-adapter-builder.webp alt: "Hermes Kanban board showing adapter request cards assigned to the adapter-builder profile" caption: "The handoff. Cards move here so the public bot never has to wait." */}
What did splitting the agents actually buy?
Responsiveness, which I had not listed as a goal.
I split the profiles for a security reason. The bot exposed to the public internet has no business holding GitHub credentials or driving a logged-in browser session. Separate profiles means separate secrets.
The payoff showed up somewhere else. The public bot never blocks. It answers, files a card, and goes back to talking. Long jobs happen somewhere the timeline cannot see, and the thread gets updated when a real pull request exists.
The bot's own instructions keep the internals private:
Kanban ids, card state, blocked notices, login instructions, branches, worker logs, verification output, and operator comments are internal. Never post them on X.
Nobody wants to watch your build logs. They want the link when it is ready.
What is still broken
One thing, and it is not fixable from my side.
The bot only sees posts that mention it. If somebody deletes the handle from their reply before sending, that message never reaches the bot at all. It is invisible, and the conversation looks to the bot like it stopped.
I know of no clean fix that does not mean polling entire conversations, which costs API calls on every thread the bot has ever touched. For a bot answering requests, missing a de-mentioned aside is survivable. If you are building something that needs to follow a whole discussion, budget for that early.
What I would tell you before you build one
Five things, roughly in the order they cost me time.
| Lesson | What it means in practice |
|---|---|
| Mentioned is not addressed | Write the classifier before launch, not after your bot replies to your own ad |
| Silence needs to be a first-class output | If your framework cannot return "nothing", you cannot fix this in a prompt |
| Read stale PRs as design docs | Salvage the intent, rewrite the code, skip the merge conflict |
| Try prose before you try state | I designed a buffer for a problem the prompt already solved |
| Split the agent that waits from the agent that talks | Public responsiveness and credential safety come from the same seam |
The one I would repeat loudest is the fourth. My instinct on seeing a behavior bug in an agent is to reach for control flow, because that is what I know how to test. Sometimes the agent already has the information and the ability to act on it, and is missing only the instruction. Check for that first. It is a much smaller diff.
If you want to see the bot work, ask it for a website. It takes requests in the replies, and the adapters it builds are open source. WebCMD itself installs with npm install -g @agentrhq/webcmd if you would rather skip the queue and point it at your own sites: the WebCMD docs cover the local setup.
FAQ
How does an AI bot decide whether to reply on X?
Give it explicit categories and a default. Mine sorts every incoming post into directed request, reference only, or overheard, then replies only to the first. When it cannot tell, it stays quiet. That default matters more than the categories, because ambiguity is common and a wrong public reply cannot be taken back.
Why does my bot reply to messages meant for other people?
X adds the handles of everyone in a reply chain to new replies in that chain. So a message written to another human arrives in your bot's mentions carrying your handle. Your bot is not misreading anything. It is being told, structurally, that it was tagged.
Do I need a paid service for bot turn-taking?
Not for a single bot on a single platform. I fixed mine with about forty lines in a prompt file. Hosted options like Humalike make more sense when you want persona work, theory of mind, or consistent social behavior across several chat platforms at once.
What is a Hermes profile?
An isolated agent instance with its own credentials, sessions, memory, and system service. Running two profiles means running two independent agents on one machine. I use one for the public X bot and one for the builder that handles credentials.
Should I run a browser agent locally or in the cloud?
Locally while you are still finding the failure modes. Hosted browser infrastructure is worth it for always-on background agents, but hosted and local environments keep separate state, so treat them as different setups rather than a toggle.
How do you stop a bot from leaking internal state in public?
Name the forbidden categories in its instructions, and keep the sensitive work in a different profile. Mine is told never to post Kanban ids, branches, worker logs, or operator comments. The stronger protection is that the public bot never holds those credentials in the first place.
Can one agent handle both talking and long-running work?
It can, and it feels responsive right up until a job needs a human. Then the conversation stalls with no explanation. Splitting the roles keeps the public side answering while the slow work happens out of view.