A self-improving personal AI agent: a terminal agent loop with persistent, searchable memory and a skill library it writes to itself. Modeled after the architecture of NousResearch/hermes-agent ("the agent that grows with you"), scoped down to the core loop: no multi-backend deployment, no messaging gateway -- just the learning loop, built clean and working end to end.
- Agent loop. Streams a response from an LLM, executes any tool calls it makes, feeds the results back, and repeats until the model is done talking. Provider-agnostic: Anthropic, OpenAI, or OpenRouter, switchable via config with no code changes.
- Persistent memory. Every message from every session is written to a
local SQLite database with an FTS5 full-text index, so the agent can
search_memoryacross months of past conversations instead of forgetting everything when the process exits. - Skills. When a turn takes enough tool calls to look like a real
procedure, the agent is handed the transcript and asked to distill it into
a skill: a versioned markdown file with a name, description, trigger
keywords, and a step-by-step body. Skills live in
~/.hermes/skills/*.md(plain text, human-readable, compatible in spirit with the agentskills.io convention), and reusing one bumps its use count; refining one bumps its version. - CLI. A REPL with slash commands for the above (
/skills,/memory,/learn,/new,/model), multiline input and history viaprompt_toolkit, colored streaming output viarich. - Web dashboard. A password-gated Flask app (
hermes/web/) exposing the same AgentLoop through a browser: a chat panel with streaming responses and inline tool-call/tool-result rendering, a skills browser, a memory search panel, and session history -- deployable to Railway.
hermes/
config.py # env -> Config (provider, model, paths)
cli.py # REPL: prompt_toolkit input, rich output, slash commands
__main__.py # `python -m hermes` / `hermes` entry point
providers/ # provider-agnostic LLM client layer
base.py # Message / ToolSpec / ToolCall / StreamChunk / LLMProvider
anthropic_provider.py # Claude, native tool_use streaming
openai_compatible.py # OpenAI + OpenRouter (same wire format, different base_url)
registry.py # get_provider(config) factory
agent/
prompts.py # the actual prompts -- see below
tools.py # ToolRegistry: run_bash, read/write/list_dir,
# search_memory, list_skills, use_skill, create_skill
loop.py # AgentLoop.run_turn(): stream -> execute tools -> repeat
# -> learning trigger
memory/
store.py # MemoryStore: SQLite + FTS5, sessions + messages + search
skills/
schema.py # Skill dataclass <-> markdown+YAML-frontmatter file
manager.py # SkillManager: create / get / list / search / refine / record_use
learning/
curator.py # after a complex turn: summarize_session(),
# propose_skill() (asks the model to author a skill,
# parses the response, saves or refines it)
web/
state.py # AppState: one provider/memory/skills + a pool of
# live AgentLoops keyed by browser session id
app.py # create_app(): password gate, chat/skills/memory/
# session JSON+NDJSON APIs
templates/, static/ # login page + single-page dashboard (vanilla JS)
wsgi.py # gunicorn entry point: `gunicorn wsgi:app`
- User types something. It's appended to conversation history and written to the memory DB.
AgentLoop.run_turnstreams the model's response through the active provider. Text deltas are yielded for the CLI to print live; tool calls are buffered.- If the model made tool calls, each is executed by
ToolRegistryand the result is fed back into history as a tool-result message; the loop goes back to step 2. This repeats until the model responds with no further tool calls, or a hard iteration cap is hit (25, to stop runaway loops). - If the turn used at least 3 tool calls (a proxy for "this was a real,
multi-step task"), the full transcript is handed to
learning/curator.propose_skill, which asks the model to either distill it into a new/refined skill or sayNONE. - On session close, the transcript is summarized (
curator.summarize_session) and the summary is stored against the session row, so a futuresearch_memoryhit can be understood at a glance without re-reading the whole conversation.
This is the actual behavioral spec, in full, in hermes/agent/prompts.py:
MAIN_SYSTEM_PROMPT-- the agent's identity, its tool list, and its operating rules (check for an existing skill before solving from scratch, usesearch_memorybefore claiming ignorance, only write a skill for something genuinely reusable, don't fabricate tool output, be direct).SKILL_AUTHORING_PROMPT-- given a transcript, forces a strictNAME / DESCRIPTION / TRIGGERS / BODYformat (orNONE) so the response can be parsed deterministically and turned into a skill file.SESSION_SUMMARY_PROMPT-- asks for a short first-person note to future-self about what happened in the session, for memory recall.
Requires Python 3.10+.
git clone <this-repo> hermes
cd hermes
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # fill in HERMES_PROVIDER + the matching API key
hermes| Variable | Meaning |
|---|---|
HERMES_PROVIDER |
anthropic | openai | openrouter |
HERMES_MODEL |
model id for that provider (defaults per-provider if unset) |
ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY |
only the one matching HERMES_PROVIDER is required |
HERMES_HOME |
where memory.sqlite3 and skills/ live (default ~/.hermes) |
HERMES_WORKSPACE |
root directory the file/bash tools are confined to (default: cwd) |
FLASK_SECRET_KEY |
web dashboard only; required, no default |
HERMES_WEB_PASSWORD |
web dashboard only; required unless HERMES_WEB_ALLOW_NO_AUTH=true |
HERMES_WEB_ENABLE_SHELL / HERMES_WEB_ENABLE_WRITE |
web dashboard only; default false each |
$ hermes
hermes -- anthropic:claude-sonnet-5 -- workspace /Users/you/project
Type /help for commands, /exit to quit.
hermes> deploy this flask app to railway
-> run_bash({'command': 'railway login'})
...
learned a new skill: deploy-flask-to-railway
hermes> /skills
deploy-flask-to-railway (used 1x) -- Deploy a Flask app to Railway with gunicorn
hermes> /memory railway
session 1 [assistant] [...deploy the app with `railway up`...]
Run it locally:
pip install -e .
export FLASK_SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
export HERMES_WEB_PASSWORD=pick-something-strong
flask --app wsgi:app runOpen http://localhost:5000, log in with HERMES_WEB_PASSWORD. The chat
panel streams the same AgentLoop the CLI uses; the right panel has live
/api/skills and /api/memory/search views, the left sidebar lists past
sessions (click one to view its transcript read-only) and starts new ones.
By default the web surface runs with run_bash and write_file disabled
regardless of the CLI's tool set -- see Safety notes below for why, and how
to opt back in with HERMES_WEB_ENABLE_SHELL / HERMES_WEB_ENABLE_WRITE.
railway init # or `railway link` to an existing project
railway volume create -m /data # persistent disk for memory.sqlite3 + skills
railway variables set \
HERMES_PROVIDER=anthropic \
HERMES_MODEL=claude-sonnet-5 \
ANTHROPIC_API_KEY=sk-ant-... \
FLASK_SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))") \
HERMES_WEB_PASSWORD=pick-something-strong \
HERMES_HOME=/data \
HERMES_WORKSPACE=/data/workspace
railway upProcfile / railway.json run gunicorn wsgi:app with one worker,
multiple threads (--workers 1 --threads 4) -- deliberately, since the
loop pool (hermes/web/state.py) and the in-process login-attempt
throttle are plain Python dicts, not shared across worker processes.
Don't raise --workers without moving that state into something external
(Redis, a database row, etc.).
Mount the volume at HERMES_HOME (/data above) or memory and skills
reset on every redeploy. Point HERMES_WORKSPACE at an empty directory on
that same volume, not the app's own source checkout -- see Safety notes.
run_bash executes arbitrary shell commands with the permissions of
whatever user runs hermes, confined only to a working directory (cwd=)
-- it is not sandboxed, containerized, or permission-gated the way the
upstream Hermes agent's Docker/SSH/Modal/Daytona backends are. read_file
/ write_file / list_dir do refuse to resolve outside HERMES_WORKSPACE,
but run_bash can still touch anything that directory's owner can. Don't
point HERMES_WORKSPACE at anything you wouldn't hand a junior engineer
unsupervised shell access to, and don't wire this up to an untrusted input
source without adding a real sandbox first.
The web dashboard raises the stakes on this, since it's a chat UI reachable from the internet rather than a terminal only you can type into. Mitigations already in place:
run_bashandwrite_fileare excluded from the tool list entirely on the web surface by default (HERMES_WEB_ENABLE_SHELL/HERMES_WEB_ENABLE_WRITE, both defaultfalse) -- the model never even sees them as available tools.- The whole app is behind a single shared password
(
HERMES_WEB_PASSWORD), checked with a constant-time comparison, with a simple in-memory lockout after 5 failed attempts from one IP in 5 minutes.FLASK_SECRET_KEYis mandatory; the app refuses to start without it. create_app()refuses to start at all ifHERMES_WEB_PASSWORDis unset, unless you explicitly setHERMES_WEB_ALLOW_NO_AUTH=true(local/trusted networks only -- never on a public deployment).
Even with the gate up: point HERMES_WORKSPACE at an empty, disposable
directory when running the web app, not the repo checkout itself, so
read_file/list_dir have nothing sensitive to expose if the password
ever leaks. This is a personal single-user tool, not a hardened multi-tenant
service -- don't share the password with anyone you wouldn't hand your API
key to directly.
pip install -e ".[dev]"
pytestCovers the memory store (session/message roundtrip, FTS5 search including
special-character queries, session summaries), the skill manager (create,
duplicate rejection, use-count/version bumps, search), the tool registry
(file I/O, workspace path-traversal rejection, bash execution, skill
create/list/use), and the web app (auth gate on / and /api/*, login
success/failure, fail-closed startup when FLASK_SECRET_KEY or
HERMES_WEB_PASSWORD is missing) -- all without needing a live API key.
The provider and agent-loop streaming/tool-calling paths are exercised by
manual smoke testing (CLI and web dashboard) but have no API-key-free unit
coverage yet, since they require a live model.
No Docker/SSH/Singularity/Modal/Daytona backends, no Telegram/Discord/ Slack/WhatsApp/Signal gateway, no cron scheduler, no Honcho user modeling, no MCP server support, no voice transcription. This is the core learning loop -- agent + memory + skills + CLI -- built to actually run, not a stub of the full product surface.
MIT