PHP library — connect a PHP site in an hour
On this page
An Isto Intents intent server for any PHP site: PHP 8.1 or newer, no dependencies beyond PDO and JSON. Download it (ZIP). On WordPress, use the plugin instead — it is this library with a settings page.
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_intentover Streamable HTTP, with the field names of the exact interface andstructuredContent; - consent pages on your own domain: the person types their e-mail there and confirms it from the e-mail (double opt-in); an AI assistant never sees or passes a contact, and a link opened by a mail scanner confirms nothing;
- a
consentUrlwith its own token, never theintentCode; - drafts that expire after 24 hours; a hidden price limit that is sealed — never returned, never
shown — as
hiddenLimitEur,hiddenLimitCzk, … or as onehiddenLimit { amount, currency }; - refusal of any argument the schema does not declare, so no e-mail can slip in;
- the
x-isto-forwarderproof (HMAC-SHA256 of your host, ±300 s, constant-time comparison) and thex-isto-testmarker; - the holder descriptor for
/.well-known/isto-intents.json; - limits per network (IPv6 per /64): new drafts per hour and all requests per hour, with a separate, larger limit for verified forwarder calls;
- storage in SQLite or MySQL/MariaDB through PDO. The
intentCodeand the confirmation tokens are stored only as SHA-256, so a copy of the database cannot read, confirm or withdraw anything.
What it deliberately does not do: decide which of your listings fit a wish. That is your search. The library hands you each confirmed wish and delivers what your search returns.
1. Install (5 minutes)#
Unpack the ZIP anywhere outside your web root and require its autoloader:
require '/path/to/php-holder/autoload.php';
With Composer, add the unpacked folder as a path repository and require istointents/holder; the
namespace is IstoIntents\Holder\ (PSR-4, src/).
2. Answer three paths (15 minutes)#
examples/index.php is a complete holder in thirty lines. In your own front
controller:
use IstoIntents\Holder\{Config, FrontController, Holder, Keywords, NativeMailer};
use IstoIntents\Holder\Storage\{PdoDb, Storage};
$config = new Config('www.your-portal.pt', 'Your Portal', 'mailto:info@your-portal.pt');
$config->operatorCountry = 'PT';
$config->countries = ['PT'];
$config->categories = ['vehicle']; // or ['property'], or both
$config->languages = ['pt', 'en'];
$config->currencies = ['EUR'];
$config->consentText = 'I want to receive e-mails with listings that match this wish …'; // the text of your other alerts
$storage = new Storage(new PdoDb(new PDO('mysql:host=localhost;dbname=portal;charset=utf8mb4', $user, $pass)));
$storage->install(); // CREATE TABLE IF NOT EXISTS: safe on every start
$holder = new Holder($config, $storage, new NativeMailer('no-reply@your-portal.pt'), new Keywords());
if ((new FrontController($holder, $config))->run()) {
exit; // it was one of ours
}
// … the rest of your site
FrontController answers, and nothing else:
| path | what |
|---|---|
POST /mcp/intents |
the MCP server (Streamable HTTP, JSON answers) |
GET/POST /intent/{confirm,verify,stop}/<token> |
the pages a person opens |
GET /.well-known/isto-intents.json |
the holder descriptor |
If your web server does not hand /.well-known/… to PHP, save the descriptor as a static file:
json_encode($holder->descriptor()). Using a framework instead? Call $holder->handleMcp(),
$holder->handlePage() and $holder->descriptorResponse() from your own routes; each returns a
plain object you copy into your framework's response. A Page is a title and an HTML fragment with
every value escaped, so it fits into your own layout.
Behind a proxy or CDN, give FrontController a function that reads the visitor's address from the
proxy's header — but only if every request passes through that proxy, otherwise a caller can choose
the address and escape the limits.
3. Plug in your mailer and your search (30 minutes)#
NativeMailer uses PHP's mail(). Most hosts deliver better through their SMTP service: implement
Mailer::send($to, $subject, $text, $headers) with whatever you already use.
Your search implements one method:
use IstoIntents\Holder\{ActiveIntent, Listing, MatchFinder};
final class MyFinder implements MatchFinder
{
public function find(ActiveIntent $intent): iterable
{
// $intent->statement, ->niceToHave, ->buyBy; ->hiddenLimitAmount / ->hiddenLimitCurrency
// are for filtering only — never show them.
foreach (my_search($intent->statement, maxPrice: $intent->hiddenLimitAmount) as $row) {
yield new Listing("https://www.your-portal.pt/car/{$row['id']}", $row['title'], $row['price'], 'EUR');
}
}
}
Run it from cron, e.g. every hour:
$holder->runMatching(new MyFinder(), perIntent: 5);
Each listing is delivered once per wish: e-mailed with a stop link (and a List-Unsubscribe header)
and shown to the assistant by intent_list_matches. Its url must be https on your host — a
forwarder refuses anything else. Messages a person reads are English by default; pass your
translations in Config::$messages (keys in Messages::DEFAULTS).
Keywords is a deliberately plain reading of a wish — its words, and a maximum price when the
sentence names one — which tells the assistant what you will watch for. Replace it with your own
Understanding when your search understands more.
4. Get listed (10 minutes)#
- Check that
https://<your host>/.well-known/isto-intents.jsonanswers.Config::$registryListing(on by default) asks to be listed. - 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, set
$config->forwarding = true; the descriptor then asks for forwarding under the forwarding terms.
5. Optional: recognise the forwarder#
The forwarder at https://istointents.com/mcp reaches you from one shared address, so it would hit
your per-network limit. It signs every call with a key issued for your host alone. Ask for it through
istointents.com and set $config->forwarderKey; verified calls
then get their own limit. Without a key the server works the same.
6. Test calls#
A request with x-isto-test: 1 (the registry's trial run) is answered exactly like any other, but
not counted in $holder->stats() and never handed to your search.
Tests#
composer install && vendor/bin/phpunit
ISTO_TEST_PDO="mysql:host=127.0.0.1;dbname=test" ISTO_TEST_USER=… ISTO_TEST_PASS=… vendor/bin/phpunit
22 tests: the exact interface, the sealed limit in both styles, the refusal of undeclared arguments, the double opt-in (a mail scanner confirms nothing, first come only, a failed confirmation e-mail leaves the link usable), escaping, delivery on your host only, withdrawal and expiry, the forwarder proof against a value computed by the Node holder, the limits (IPv6 per /64), test calls, restarts and the descriptor. They pass on PHP 8.1 and 8.2, with SQLite and with MariaDB 11.4.
Licence: GPL-2.0-or-later (LICENSE.txt), the same as the WordPress plugin built on it; the specification and the rest of the protocol's public part stay under CC BY 4.0.