Reference holder — connect your portal in an hour
On this page
A complete Isto Intents intent server in one file: holder.mjs (Node 20 or newer, no
dependencies) with its tests, holder.test.mjs. It implements the
consent-link profile exactly as the registry check
and the forwarder expect it:
- the four MCP tools
intent_publish_intent,intent_get_intent,intent_list_matchesandintent_withdraw_intent, with the field names of the exact interface andstructuredContent; - a consent page on your own domain where the person types their e-mail and confirms twice (double opt-in); the AI assistant never sees or passes a contact;
- a
consentUrlwith its own token, never theintentCode; - drafts that expire after 24 hours, a hidden price limit per currency (
hiddenLimitEur, …) that is sealed — never returned, never shown; - refusal of any argument the schema does not declare (so no e-mail can slip in);
- the optional
x-isto-forwarderproof and thex-isto-testmarker.
What it deliberately does not do: decide which of your listings fit a wish. That is your search, your ranking and your notifications. The server hands you the confirmed wishes and records what your search delivers.
1. Run it (5 minutes)#
HOLDER_HOST=www.your-portal.pt HOLDER_OPERATOR="Your Portal" HOLDER_CURRENCIES=EUR PORT=8787 node holder.mjs
| variable | meaning |
|---|---|
HOLDER_HOST |
your public host — the one that will publish the descriptor |
HOLDER_OPERATOR |
your name as people should see it |
HOLDER_CURRENCIES |
currencies you accept for a hidden limit, e.g. EUR or PLN,EUR |
HOLDER_DATA_FILE |
where the state is kept (default holder-data.json) — it contains e-mail addresses, so treat it as personal data |
TRUST_IP_HEADER |
behind a proxy: the header with the client address, e.g. cf-connecting-ip |
FORWARDER_KEY |
optional, see §5 |
2. Put it on your domain (15 minutes)#
Route these two paths of https://<your host> to the process (any reverse proxy):
/mcp/intents— the MCP server (POST, Streamable HTTP, JSON answers)/intent/— the confirmation pages people open
3. Plug in your mailer and your search (30 minutes)#
Use it as a module instead of the command line:
import { createServer } from "node:http";
import { createHolder } from "./holder.mjs";
const holder = createHolder({
host: "www.your-portal.pt",
operator: "Your Portal",
currencies: ["EUR"],
dataFile: "/var/lib/holder/data.json",
sendMail: ({ to, subject, text }) => yourMailer.send({ to, subject, text }),
consentText: "I want to receive e-mails with listings that match this wish …", // the same text as your other channels
});
createServer((req, res) => holder.handle(req, res)).listen(8787);
// Your search, as often as you like (e.g. every hour):
for (const wish of holder.activeIntents()) {
// wish: { id, statement, niceToHave, buyBy, hiddenLimit, activeSince, delivered }
for (const listing of await yourSearch(wish.statement, { maxPrice: wish.hiddenLimit?.amount })) {
await holder.deliver(wish.id, { url: listing.url, title: listing.title, price: listing.price });
}
}
deliver() sends each listing once, e-mails it with a stop link and makes it visible to
intent_list_matches. Its url must be on your host — a forwarder refuses anything else.
Optionally give understand: (statement) => ({ understood: [...], notUnderstood: [...] }) so the
assistant can tell the person what you will and will not watch for.
4. Get listed (10 minutes)#
- Publish
https://<your host>/.well-known/isto-intents.json— copy the example descriptor, set your host, operator, countries, categories and languages, and pointservers[].urlathttps://<your host>/mcp/intentswith"profiles": ["consent-link"]. - On istointents.org/join check your host and tick trial run: the registry calls your four tools once, as a forwarder would, and tells you whether you are ready.
- Submit:
POST https://istointents.org/registry/v0/submitwith{ "host": "<your host>" }. - To receive wishes from people whose AI assistant does not know you yet, add
"forwarding": { "accept": "requested", "terms": "https://istointents.org/forwarding/terms/" }to the descriptor. Demand by country shows what is being asked.
5. Optional: recognise the forwarder#
The forwarder at https://istointents.com/mcp reaches you from one shared address for all of its
users, so a per-network limit would put them all in one bucket. It signs every call
(forwarding §3 rule 6):
x-isto-forwarder: <sig>.<ts> sig = base64url(HMAC-SHA256(your key, "<ts>|<your host>")), ts = Unix seconds
Your key is issued per host: it opens only your door, so nobody else who verifies the header can
sign calls to you, and you cannot sign calls to anybody else. Ask the forwarder's operator for it
through istointents.com and set it as FORWARDER_KEY.
holder.mjs then checks the signature against your host from your own configuration (never the
request's Host header), accepts ±300 seconds, compares in constant time and gives verified calls
their own, larger limit. Without a key the server works the same; it just cannot tell forwarder
calls apart.
6. Test calls#
A request with x-isto-test: 1 (the registry's trial run, or a forwarder relaying a test) is
answered exactly like any other, but not counted in holder.stats and never returned by
activeIntents() (MCP binding, profile rule 8).
Tests#
node --test holder.test.mjs
They cover the exact interface, the sealed limit, the refusal of undeclared arguments, the double opt-in (a link opened by a mail scanner confirms nothing), first-come consent, delivery on your host only, withdrawal, expiry, the forwarder proof (wrong host, expired, wrong key) and restarts.
Licence: CC BY 4.0, like the rest of the protocol's public part.