PROJECT self-hosted · MIT · Discord and Telegram
Hermes Secret Drop
Give your agent a password without pasting it into the chat.
The agent posts a one-time link. You open it, type the secret in your browser, and the agent gets it without the secret ever becoming a chat message. It works the other way too: the agent can hand you a password through a link instead of writing it out.
Not end-to-end encrypted, not security-audited. What it won't do
You
Deploy staging. You'll need the database password.
Hermes agent
Private input requested Received
drop.example.com/#7fQ2…
Open it to send text or files. Expires in 30 minutes.
you open it in your browser
Send privately to Hermes
masked example value
Encrypted in this browser before it's sent.
Hermes agent
Got it, it came through the link. Deploying staging now.
- You ask in Discord or Telegram, as usual.
- The agent posts a link into the same chat. It has no way to send it anywhere else.
- You type the secret in your browser. It's encrypted there, before it leaves.
- The agent gets it. The chat only ever shows the link.
Illustration with dummy values. Not a screenshot, not a live demo.
01 · the problem
A pasted secret leaves copies
Ask for a password in chat and the password is now in the chat: in the history, in the agent's logs, in whatever the model keeps. “Just don't log it” doesn't hold up. The platform stores the message, the agent writes tool results to its session store before the model reads them, and the request passes whatever sits in front of the server.
You
here you go: p4ss-EXAMPLE-0000
Where that line ends up
- Chat history, on the platform, for anyone who can scroll up. Drop keeps it out
- The agent's session log and search index, written before the model even reads it. Drop keeps it out
- Whatever sits in front of the server: proxies that end TLS, request-body logs. sealed first, needs HTTPS
- The model and your model provider. When you give the agent a secret, it is because the agent needs it. still true with Drop
02 · both directions
It works both ways, with different promises
The two directions aren't mirror images. Who ends up seeing the value is different, and that is the most important thing to know about Drop.
you → agent
Give the agent a key or a file
Private text, or up to five files (42 MiB in total). Type /drop, or let the agent ask.
- 1
Private input requested
A link in the chat you're already in.
- 2
masked example value
Sealed in your browser, sent once.
- 3
Received
The same message, edited. No link left in it.
- The chat
- A link and a status card.
- The agent's session log
- A placeholder, not the value.
- The agent and your model provider
- Yes. That's the point: the agent needs it.
agent → you
Get a password from the agent
A login, a password, an API key: labelled fields, each with a Copy button, masked until you ask.
- 1
A secret for you
A link, and a 3-digit code on its own line: 417
- 2
Enter the code
A link preview can't spend it. Opening it takes the code.
- 3
passwordmasked example value
Opened. This link won't work again.
- The chat
- The link and the code.
- The broker, afterwards
- Only ciphertext it can't open.
- The model
- No, if the broker generated the password. Yes, if the agent passed on one it already had.
03 · compare
Paste it, or drop it
The same task done two ways. Drop is the highlighted column. Read the last rows too.
| Paste it in the chat | Hand it over with Drop | |
|---|---|---|
| Shows up as a chat message | Yes, in the history | No. The chat shows a link and a status card. |
| Saved in the agent's session log and search index | Yes | No. A placeholder is saved instead. |
| Readable by proxies and logs in front of the server | The platform has it anyway | No, it's sealed in your browser first. Only with real HTTPS. |
| Can be opened again | Whenever someone scrolls up | No. One claim or one reveal, then it's destroyed. |
| How long it lasts | Until someone deletes it | Minutes. An inbound drop lasts 30 by default, 1 to 60 configurable. A broker restart drops every pending one. |
| Reaches the model and your model provider | Yes | Yes, when you give it to the agent. No, when the broker generates a password for you. |
| Protects you from the host, the broker or the model | No | No. They are all trusted with the plaintext. |
04 · limits
What it won't do
The short version of the project's threat model and limitations. Read the full ones before you trust it with anything that matters.
- It isn't end-to-end encryption. The broker holds the inbound decryption key by design. Your Hermes host, the broker process and the model are all trusted with the plaintext.
- It hasn't been audited. No formal or third-party security audit. Its HPKE library says the same of itself. There's a written threat model and a lot of automated tests, including RFC 9180 vectors; that isn't an audit.
- It needs real HTTPS. The page that encrypts arrives over the same connection. Whoever can rewrite that connection can rewrite the page, so authenticated HTTPS is load-bearing.
- The code isn't a password. The 3-digit code travels in the same chat as the link. It stops link previews and virus scanners from opening a drop first. It doesn't prove who you are.
- One look, then it's gone. An opened outbound drop can't be opened again, so a page reload costs the secret. Three wrong codes destroy it.
- Password forms are refused today. A form with a secret field must go to a consumer that runs without the model. None ships, so Drop refuses before it posts a link.
- “Destroyed” is best-effort. Removed from memory and zero-filled. Nothing is claimed about swap, core dumps or snapshots.
- Discord and Telegram only. Any other platform is refused by name, not downgraded.
05 · setup
Run it yourself
Both pieces are yours to host: a small Node.js broker behind your own HTTPS reverse proxy, and a Hermes plugin that adds /drop. It runs on released, unmodified Hermes.
Ask your agent
Copy this into the coding agent you already use and let it do the setup. It reads the README, looks at your machine and asks you only what it can't decide itself. You still need a server and a domain with HTTPS; the prompt doesn't conjure those.
Set up Hermes Secret Drop for me: its self-hosted broker behind HTTPS, and its Hermes plugin. Source of truth: https://github.com/dmmeteo/hermes-secret-drop Read the README first, especially Deploying the broker, Installing the Hermes plugin, Verifying an install, Threat model and Limitations. Follow the documented install path; don't invent your own. 1. Inspect this environment before changing anything: OS, Docker and Compose, Node.js, the reverse proxy and how it does HTTPS, any existing Hermes install and its profiles. Tell me what you found. 2. If something required is missing (a public hostname, DNS, working HTTPS, permission to run Docker or create directories), stop and tell me what's missing and my options. 3. Ask me only for real decisions, like the hostname, the Hermes profile and the socket directory. Never ask me to paste a password, token or key into this chat. If a step needs one, tell me where to put it myself. 4. Ask before anything that exposes a service publicly, opens ports, or deletes or overwrites existing config, containers or data. The plugin needs a gateway restart; ask before restarting it. 5. Verify the way the README does: the preflight check, a test drop from the broker, the plugin listed after the restart. Then I'll type /drop in Discord or Telegram and check the link arrives in that same conversation. 6. Finish with a short summary: what you installed, where, what you changed, what's left for me. Don't call it end-to-end encrypted or audited. The README says it's neither.
Install manually
Old school, and fair enough: the same setup, typed by you, straight from the README.
Try the broker locally
No Hermes needed. Mint a link, open it, claim the value once.
Quick startnpm ci npm run verify npm start & node bin/handoff-admin.mjs createDeploy it behind HTTPS
Set your host name, create the socket directory, build. The supplied
compose.ymltargets Traefik.
Deploying the brokercp .env.example .env docker compose build docker compose up -dAdd the plugin to Hermes
Install into a named profile, point it at the broker's socket, restart the gateway. Then type
/drop.
Installing the pluginHERMES_HOME="$HOME/.hermes" \ bin/install-hermes-drop.sh install
There's also a standalone Claude Code command with a narrower boundary: text in, generated credentials out.
06 · all of it
How it works, in full
The promises, the lifecycles and where the secret sits at each point. Open what you need.
Why “just don't log it” isn't enough
I wanted a Hermes agent to be able to receive a credential or a file without either one ever landing in the conversation. Asking for a secret in chat means the secret is now in the chat — in the transcript, in the logs, in whatever the model keeps.
The obvious fix, “just don’t log it”, doesn’t hold up. A chat message is sent to the platform, a tool result is written to the session store before the model sees it, and a request body passes through whatever sits in front of the server. So Hermes Secret Drop takes the secret out of the chat entirely: the agent posts a short-lived link, and the value goes through a web form instead.
Two directions, two different promises
Hermes Secret Drop is two self-hosted pieces: a small Node.js broker behind your
own HTTPS reverse proxy, and a Hermes plugin with a stock /drop skill
command. It works both ways, but the two directions are not mirror images,
and the difference is the most important thing to understand about it.
Inbound, you give the agent something. The broker makes a fresh key pair for that one drop, your browser seals what you type or attach to its public key, and the broker — which holds the private key — opens it and hands it to exactly one claim. The agent gets the plaintext, so the model and your model provider see it.
Outbound, the agent gives you something. The broker encrypts it with a
one-use key, keeps only the ciphertext, and puts the key in the link’s
#fragment. Your browser decrypts it after you type a 3-digit code. The
broker can’t read it afterwards, but the plaintext did pass through it to
be encrypted. If the agent is relaying a value it already had, that value
is in its own tool call and transcript anyway. If it asks the broker to
generate one, the value never enters the model at all.
Same conversation, or nothing
The part I keep coming back to is that the link has no destination field. Not in the command, not in the tool schema — no platform, channel or thread at any depth. A model that cannot express a destination cannot pick the wrong one.
The link goes back into the conversation the request came from, and the
plugin checks that against the gateway’s own session context before
posting. If it can’t verify it, it refuses (origin_mismatch,
origin_unverified, no_origin). It never falls back to a home channel.
Only Discord and Telegram are supported, and any other platform is refused
by name rather than downgraded. That turned out to be a much better
boundary than validating a destination the model supplied.
Once, then gone
Every drop is one-shot, and its states only move forward. A second claim gets the same generic “unavailable” as a wrong link, so from the outside a spent drop and a wrong guess look the same.
The link carries a 128-bit capability in its #fragment, which browsers
never send to the server, so it never shows up in a request line, an
access log or a Referer. The broker keeps only a hash of it. Inbound
drops live for 30 minutes by default (1 to 60 can be configured), and
three failed decryptions destroy one. On the way out, three wrong codes
destroy the payload. Losing a delivery is the deliberate price for not
allowing online guessing. The code is a human-presence and anti-preview
gate, not authentication: it travels in the same chat as the link. What
it does is stop a link unfurler or a virus scanner from opening the drop
first. A GET never consumes it.
One trade-off is easy to miss: an opened outbound drop is gone. A page reload costs the secret.
Where the secret is, and isn't
“Not in the chat” is only half an answer, so here is the whole map. The broker keeps everything in memory — no database, no Redis, no analytics, no CDN, no third-party script — and a broker restart destroys every pending drop, because the per-drop keys were never written anywhere. “Destroyed” means removed from an in-memory map and zero-filled on a best-effort basis. Nothing is claimed about swap, core dumps or snapshots.
On the Hermes side, a claimed secret is swapped for a placeholder before the tool result is written to the session store, the search index or the session log. The plaintext is held in gateway memory for at most 15 minutes and put back only into the copy of the request sent to the model provider.
| Place | You → agent | Agent → you |
|---|---|---|
| Chat message | Link, no value. Its #fragment holds the capability: enough to submit once, not to read. Received and expired states carry no link. | Link and code, no value. Its #fragment holds the capability and the AES key; with the code, that opens it once. Keep the chat as private as the value. |
| Your browser | Plaintext while you type; sealed before it is sent. | Plaintext after you reveal it, decrypted on the page. |
| Broker memory | Plaintext from opening until the one claim or expiry. It holds the key. | Ciphertext it cannot open; the plaintext passed through it to be encrypted. |
| Server side: broker disk, request URLs, access logs | No plaintext written, no payload store. The capability goes in a header and is kept only as a hash. | No plaintext, no key. The code is kept as an HMAC, never logged. |
| Hermes gateway memory | At most 15 minutes. At most 4 per session, 32 per gateway, then the model must ask again. | Relayed: plaintext, in the tool call on its way to the broker. generate: never — the broker draws it. |
| Session store, search index, session log | A placeholder ([hermes-drop:secret:…]), written instead of the value. | Relayed: plaintext, in the tool call Hermes stores. generate: never. |
| Model context, provider request | Plaintext. That is the point of an inbound drop. | Relayed: plaintext, where it already was. generate: never. |
| Files on disk | Spooled as 0600 files under a 0700 folder; the model gets paths, not bytes. | No outbound files. |
Forms as data, not presets
A plain /drop gives the universal form: the person chooses private text
or up to five files (42 MiB in total). A request can also describe its
own form as a short, closed contract: an ordered list of fields, each with
an id, a label and one of five types — text, email, textarea,
secret, files. Drop renders it, validates it on both sides, binds it
into the encryption, and returns the values keyed by id. Nothing in it is
silently repaired; a contract that breaks a rule is refused with a code.
The rule that matters most is that delivery is derived, never chosen.
A form with no secret field goes to the model, like every inbound drop.
A form with a secret field is forced into a different lane: the whole
submission goes to an authorized consumer that runs without the model, and
the model gets a receipt with no values in it.
Today no such consumer ships. A form with a secret field is refused
before a link is posted. The plugin’s consumer registry is empty on
purpose, so a password form fails at mint with secret_consumer_unavailable.
Drop doesn’t ask anyone to type a password it could not receive privately.
The test suite has a canary consumer, but it is never registered, and it is
not vault, BWS or .env integration. The private text lane of the
universal link is a different thing. It works today, and what you type
there reaches the model, the same as every inbound drop.
HTTPS is the part you can't skip
Encrypting in the browser only means something if the browser runs the page the broker meant to send. The JavaScript that does the encryption arrives over the same connection as everything else. If an active attacker can rewrite that connection, they can rewrite the code. The swapped page looks the same, but it reads your input before encryption, or seals it to a key the attacker holds. No cipher in that page can help, because whoever wrote the page decides what it encrypts. In the threat model’s own words: authenticated HTTPS is load-bearing; it authenticates the JavaScript that performs the encryption.
There is also a plainer reason: browsers only expose crypto.subtle in a
secure context, so on a public host over plain HTTP the page has nothing
to encrypt with. Browsers also treat loopback addresses as secure, which
is why the local quick start works on 127.0.0.1. That is a developer
convenience, not a deployment mode. Drop’s own docs say plain HTTP gives no
protection against an active network attacker, and they rule reports
against deployments without HTTPS out of scope.
What it does not do
It is not end-to-end encrypted. The broker holds the inbound
decryption key by design, and the Hermes host, the broker process and the
model are all trusted with the plaintext. Browser-side encryption protects
against hops that terminate TLS or log request bodies in front of the
broker, not against any of those trusted parties. The claimed value enters
the model’s context and goes over the wire to your model provider; if the
model echoes it, that echo is stored like any other output. Anything that
reads the request after the swap sees it too: an observability hook, NeMo
Relay, HERMES_DUMP_REQUESTS=1. None of them should be on for a gateway
that handles drops.
It has had no formal or third-party security audit. It was built against a written threat model and has substantial automated test coverage, including RFC 9180 test vectors, but that is not the same thing, and the underlying HPKE library says the same of itself. The consumer boundary is not protection against a malicious plugin on the Hermes host, and exactly-once delivery is not claimed for a future consumer either.
Read these before trusting it with anything that matters:
- Threat model and Limitations, in the README.
- Security policy and scope, including both lifecycles and what deletion is worth.
- The declarative form engine, and why the consumer lane ships closed.