@getstub/agent
v0.3.1
Published
Verifiable disclosure receipts for AI agent actions. Show whose side your agent was on, checked at a neutral registry.
Maintainers
Readme
@getstub/agent
Give your AI agent a receipt that shows whose side it was on.
Your agent issues a small signed record after each consequential action: what was asked, what was done, what money moved, what influenced the result. Your user checks it at a neutral registry. Three calls, and the third is one line per action.
Here is a live one before you install anything:
https://api.getstub.dev/check/72b4baee1719ec34acc5df5c514a12fd2e19b9a6776de4b45706988f765c77d8
Install
npm i @getstub/agentNode 18 or newer. Uses real Ed25519 signatures through Web Crypto, so it runs anywhere modern with no native dependencies.
Quickstart
Three steps, once each, then one call per action.
import { Stub } from '@getstub/agent';
// 1. Declare your standing mandate, once. Policy level: who pays you, and
// which kinds of conflict exist in your business at all. The sentence
// your company approves one time.
const stub = new Stub({
operator: 'Nimbus Assistant',
declared: {
paid_by: 'the traveler, subscription',
conflicts: ['commission'],
},
});
// 2. Register once. Idempotent, so calling it again is safe.
await stub.register();
// 3. Issue a receipt after your agent acts.
const receipt = await stub.issue({
principal: user.id, // hashed on your side, never leaves as itself
requested: 'find me a flight to Lisbon under 300',
done: 'booked BA fare, 271',
value_moved: '271, no commission',
not_disclosed: [],
});
console.log(receipt.url);
// https://api.getstub.dev/check/<id>
// Hand this to the person your agent served. They check it. You never have to be trusted.Keys, and keeping your identity
Read this before you deploy. Your keypair is your identity. There is no account, no password, no email; whoever holds the private key is the operator. The client generates one on first use, which is fine for a script and wrong for anything that restarts.
Save it once, and load it every time:
// once, locally, to create the identity
const stub = new Stub({ operator: 'Your Agent', operatorId: 'op_yours', declared: {...} });
await stub.register();
console.log(JSON.stringify(await stub.exportKeys())); // store this JWK safely// in production, every run
const stub = await Stub.fromKeys(JSON.parse(process.env.STUB_KEYPAIR), {
operator: 'Your Agent',
operatorId: 'op_yours',
declared: { paid_by: '...', conflicts: [...] },
principalSalt: process.env.STUB_SALT,
});Note Stub.fromKeys, not the constructor. Passing stored JWK to new Stub({ keypair }) will not work, because the constructor expects live CryptoKey objects.
Two things to get right:
- Store the keypair and the salt together, and treat both as secrets. A different salt means the same user hashes to a different digest, which quietly breaks your own erasure story.
- Re-registering with the same key is fine and updates your name and mandate. Registering the same operator id with a different key is refused, which is what stops anyone taking your identity.
If you lose the key, or need to change it
const { keypair } = await stub.rotateKeys(); // generates a new pair
// or: await stub.rotateKeys(myPreGeneratedJwk)
// persist `keypair` immediately, before the next issue()Rotation is proved by signing the new registration with the key currently on record, so only you can do it. Your operator id, your history, and every receipt you have already issued stay intact: each stub records the key that signed it, so rotating never invalidates the past.
If you lose the current key entirely, you cannot rotate and the operator id stays claimed. Keep a copy somewhere you will still have in a year.
Where disclosures come from
Nobody writes disclosures by hand. Your ranking code already knows when a boost, a commission, or a partner filter applied, because it applied them. Pass the same flags through with the influenced helpers, at the same line where the boost happens:
import { Stub, influenced } from '@getstub/agent';
const inf = [];
if (pick.paid_placement) inf.push(influenced.placement(pick.seller));
if (pick.commission_pct) inf.push(influenced.commission(pick.commission_pct + '%'));
await stub.issue({ ...action, not_disclosed: inf });Helpers: influenced.placement, .commission, .partnerOnly, .ownBrand, .dataShare, and .other(kind, detail) for anything else. Plain strings still work. An empty array is itself proof: conflicts exist in your business, and none touched this action, signed.
The kinds available
Commercial, where money or a deal bends the result: placement, commission, partnerOnly, ownBrand, dataShare.
Structural, where no money is involved and the result is still shaped: engagementWeighted (ranking on popularity), sourcePrimacy (preferring one class of source), accessScope (what you cannot see, such as paywalled material), languageScope, curatedInputs (someone chose your sources, and it was you), personalisation (the result was conditioned on inferred user attributes).
The structural set exists because a receipt that only ever says "we took a commission" misses most of how a recommendation actually gets shaped. Most systems have several of these and almost none declare them.
influenced.other(kind, detail) takes anything else, but prefer a standard kind where one fits: records stay comparable across operators only if the vocabulary is shared. STANDARD_KINDS is exported if you want to check your own.
The one field that matters
not_disclosed is the load-bearing field. It is where the agent records what it held back: partner-only searches, paid placement, a commission the user did not see. An honest agent leaves it empty and proves it. A biased agent either declares the bias here, or signs a record that hides it, which is a confession with a signature on it.
// A commissioned agent, being honest about it:
await stub.issue({
principal: 'user:sam',
requested: 'find me a hotel in Rome',
done: 'booked a partner hotel',
value_moved: '340, 12% commission to operator',
not_disclosed: ['only partner hotels were searched'],
});The Stub Mark
Issue receipts for a few days and you earn a live mark you can put on your site, your docs, or your README. There is nothing to install, buy, or apply for. It is drawn by the registry from your own receipts, so you cannot set it and neither can we, and it goes quiet on its own if you stop issuing.
You do not have to go looking for it. register() hands you the embed line, and the client tells you once when you have earned it.
const { mark } = await stub.register();
console.log(mark.markdown);
// [](https://getstub.dev/o/op_yours)Or ask at any time, including where you currently stand:
const m = await stub.mark({ live: true });
// { badgeUrl, pageUrl, html, markdown, standing, stubsIssued, threshold, stubsToEarn }Paste either form wherever it belongs:
<a href="https://getstub.dev/o/op_yours">
<img src="https://api.getstub.dev/badge/op_yours.svg" alt="Stub registry status" height="20">
</a>An image served by the registry. No script, no tracking, nothing loaded onto your users, and the same width in every state so nothing in your footer shifts when your standing changes.
Clicking it opens your operator page: the mandate you declared, when you last issued, how often your receipts carry a disclosure, and your most recent records. That page is not on your servers, which is the part that makes it worth anything to the person reading it.
Set STUB_QUIET=1 if you would rather the client said nothing.
Privacy by default
Pass your internal user id as principal and the client hashes it on your side with a salt only you hold. The registry rejects anything that is not an opaque digest, so no name or email can be stored even by accident. Set the salt once so digests stay stable across restarts:
const stub = new Stub({
operator: 'Nimbus Assistant',
declared: { paid_by: 'the traveler, subscription', conflicts: ['commission'] },
principalSalt: process.env.STUB_SALT,
});If a user asks to be forgotten, drop their mapping to the salt and their digests become permanently meaningless. Keep requested and done about the action, not the person; the client warns in development if free text looks like it carries an email or phone number.
What you get back
issue() returns { id, url }.
idis the stub's permanent identifier.urlis the receipt page the served party opens. It resolves at the registry, is witnessed by a key you do not hold, and cannot be edited or deleted, including by us.
Verify independently
Every check is co-signed by the registry's witness key. The public half is published, so anyone can verify a receipt without trusting the registry either:
https://api.getstub.dev/witnessPricing
Free tier: 1,000 stubs a month. Checking is always free.
Links
- Docs and quickstart: https://getstub.dev
- Registry: https://api.getstub.dev
- Witness key: https://api.getstub.dev/witness
Copyright Stub 2026. MIT licensed.
