Skip to content

Running Hermes Agent on a NAS: What 124 Unattended Runs Taught Me

Since July 2026 I have been running Hermes Agent, the open-source agent from Nous Research, in Docker on my home NAS. It isn’t a chat toy: cron-style timers drive it three times a day to research, write, illustrate and publish articles for a Romanian product-review site. By 20 October it had completed 124 live runs (99 new articles and 25 refreshes of existing ones), each taking about five to seven minutes.

This post covers the setup and, more usefully, the six things that broke along the way. Most of them failed silently, which is the real risk with an agent that runs while you sleep.

The setup

  • Hardware: a UGREEN NAS running UGOS, which is Debian 12 underneath, on x86_64.
  • Container: nousresearch/hermes-agent:latest with host networking. I stay on :latest on purpose and adapt my scripts when an update changes something, rather than pinning a version and falling behind. At the time of writing it reports v0.22.2.
  • Model access: my ChatGPT subscription, signed in through the openai-codex provider with OAuth device-code login, not a metered API key. The main model writes the articles. Side tasks (summaries, vision, delegation) go to a cheaper model in the same family so they don’t eat the main allowance. A typical pipeline day uses about 15 credits of the main model’s allowance.
  • Interfaces: the container’s default entrypoint runs the Telegram gateway, so I can talk to Hermes from my phone. My scripts use the CLI instead, with one-shot hermes chat -q calls through docker exec.
  • Scheduling: systemd user timers on the NAS. The pipeline runs at 06:13, 12:13 and 18:13, with retry slots that top the day up if a run fails.

The pipeline itself is a chain of small scripts, each one re-runnable on its own: keyword research, a content brief, generation with Hermes, affiliate link insertion, price verification against the live store page, image generation with an automated quality check, and a Git push to a Hugo site. Hermes is one stage of that chain, not the whole thing. Everything that has to be true (prices, stock, links) is checked by plain code, not by the model.

Problem 1: “the input device is not a TTY”

The first thing that breaks when you script Hermes over SSH is docker exec -it. Run from a script, it fails with “the input device is not a TTY”. Two fixes are needed together: force a TTY on the SSH side with ssh -tt, and give the command explicit input so it doesn’t wait on your terminal:

ssh -tt nas 'docker exec -it hermes hermes chat -q "..."' < /dev/nullCode language: JavaScript (javascript)

Long prompts have a second problem. Passing them as a command-line argument through SSH and Docker means two layers of shell quoting, and an apostrophe in the prompt breaks it. My scripts now write the prompt to a file inside the container and pass the file instead.

Problem 2: every timer died after a reboot

This one cost me ten days of runs. After a NAS reboot, systemctl --user list-timers showed nothing. No errors and no alerts, the pipeline just stopped. The cause: the systemd user manager starts at boot before /home is mounted, finds no unit files in ~/.config/systemd/user, and loads no timers.

The textbook fix, RequiresMountsFor=/home/... in a drop-in for [email protected], does not work on UGOS. UGOS mounts /home outside systemd. There is no home.mount unit, so systemd has nothing to wait for and starts the user manager immediately anyway. Two more reboots left the timers dead before I understood that.

What works is blunter: make the user manager wait until the directory actually exists. In /etc/systemd/system/[email protected]/home-mount.conf:

[Service]
ExecStartPre=/bin/sh -c 'i=0; while [ ! -d /home/YOU/.config/systemd/user ] && [ $$i -lt 60 ]; do sleep 1; i=$$((i+1)); done'
TimeoutStartSec=120Code language: JavaScript (javascript)

The doubled $$ is systemd’s escape for a literal $. It has survived one reboot so far. I don’t rely on it alone, though: each pipeline run now pings an Uptime Kuma push monitor at the end, so if the timers ever die again, I get an alert when the expected ping doesn’t arrive instead of finding out days later.

Problem 3: you can’t choose the image model

Hermes has an image_generate tool, and on the ChatGPT-subscription path it goes through the Codex backend. The plugin sends a model, a quality and a size. I checked what comes back in the response.completed event: the backend rewrites all three. A request for gpt-image-2, low, 1024x1024 comes back as gpt-image-2-codex, auto, auto. Even made-up model names still return images, so the field isn’t validated at all, just ignored.

In practice this means three things:

  • the quality tier in your config is cosmetic;
  • newer image models that are only offered on the metered API are unreachable from a subscription;
  • the requested aspect ratio isn’t honoured, so images come out portrait, landscape or square depending on the prompt.

If you need a fixed size, crop downstream. If you want to know whether OpenAI has quietly upgraded the model behind it, read response.tools[0].model from the completed response rather than trusting what you sent.

Problem 4: one failed call silently disabled every side task

At one point every side task (summaries, image descriptions, delegated sub-tasks) started failing, while ordinary chat with the main model worked fine. The cause was in auth.json. After a failed call to some model, Hermes writes a model_cooldowns entry on the credential, and while any cooldown entry exists, the side-task resolver skips that whole credential. One bad call to a model I wasn’t even using any more took out every auxiliary slot.

The fix is to clear the stale entry. My model-switching script now does that automatically whenever its own smoke test of a new model fails, because that smoke test is exactly what creates the entry.

Problem 5: OpenAI retires models out from under you

On 8 September OpenAI retired a model that the image plugin had hardcoded internally at the time, and image generation broke across the whole setup overnight. Later Hermes versions removed that dependency, but the lesson stuck: on a subscription you don’t control the model list.

A small daily job now compares the live model list from the Codex backend with every model my config uses. It sends a Telegram alert once when a newer version of a model I use appears, and every day while a configured model is missing. It never switches anything by itself. The alert includes the command to run, and that script smoke-tests the new model before it touches the config, backs up the config, then restarts and verifies.

Problem 6: updates move files, and vision quality depends on one setting

Staying on :latest has a price. Hermes v0.21.6 moved the image cache from cache/images/ to cache/generated/images/, and my script that picked up generated files broke until I changed the path. When the container changes and something breaks, I now check the version first.

The other surprise was image quality control. My pipeline asks the main model to inspect each generated image for obvious defects (detached limbs, mangled hands, garbled text) and regenerates on a fail. Under the default agent.image_input_mode: auto, attached images were first described in text by the cheaper vision side model, and the main model judged the description, not the picture. That side model passed an image with a detached forearm that the main model caught in three out of three tries. Setting image_input_mode: native sends the image straight to the main model.

What it does and doesn’t do for you

After three months, the honest summary: Hermes on a NAS is a solid, cheap engine for scheduled AI work, if you treat it as one component and put plain-code checks and alerts around it. The agent was rarely the thing that failed. The failures were in the plumbing: TTYs, mounts, credentials and model churn. All six problems above were silent, and each one now has a check or an alert.

It is not a business by itself. I ran the same kind of automated pipeline on a second, English-language site. It published 63 articles in about two months, about half of its URLs were indexed with no errors or penalties, and Google ranked almost none of them: the site fell to 2 search impressions in September. The Romanian review site, an older domain with affiliate links built in from the start, is the one that got its first affiliate orders. Automation scales whatever you point it at, including content nobody needed.

Leave a Reply

Your email address will not be published. Required fields are marked *