Profiles
Confirm terms for a task
Let the person confirm once what an agent's task may grant, then check each request against those terms before anything is signed or sent.
Alice asks her agent to let Dex, a swap app on her local replica, spend up to 500 TKA until the end of November.
Before the agent builds the request, Alice confirms those terms once, on her wallet's own screen. The wallet then
reads what the request grants and compares it with what she confirmed: a request that fits may be signed or sent
without a second yes, and one that does not is refused with its reasons. @meantjs/consent/terms holds the steps;
the rules are spec
§5.3 ("What a call grants", "Confirmed terms") and
§11 item
10, decision D-056.
Check a request against the person's terms#
npm install --save-exact @meantjs/consent@0.5.0 @meantjs/profile@0.5.0
npx candid-profile resolve ledger.did --packs > ledger.resolved.jsonimport { readFileSync } from "node:fs";
import { generateConsent } from "@meantjs/consent";
import { checkGrant, confirmTerms, readTerms, termsText } from "@meantjs/consent/terms";
// What the wallet knows: its tokens, the apps the person named, its clock.
const LEDGER = "bkyz2-fmaaa-aaaaa-qaaaq-cai"; // TKA, a ledger on your local replica
const wallet = {
tokens: { TKA: { canister: LEDGER, decimals: 8 } },
apps: { Dex: "bd3sg-teaaa-aaaaa-qaaba-cai" },
now: BigInt(Date.parse("2026-11-02T12:00:00Z")) * 1_000_000n, // nanoseconds
};
// 1. The agent writes the person's terms, a tool's arguments; the wallet reads them.
const terms = readTerms({ token: "TKA", spender: "Dex", max_amount: "500", expires_by: "2026-11-30" }, wallet);
// 2. The wallet shows them on its own screen. Once the person confirms that text:
const shown = termsText(terms);
const confirmed = confirmTerms(terms);
// 3. The agent builds its request, an icrc2_approve; the wallet receives its bytes.
const resolved = JSON.parse(readFileSync("ledger.resolved.json", "utf8"));
const argHex =
"4449444c086c08c6fcb60201ba89e5c20402a2de94eb060282f3f3910c04d8a38ca80d7d919c9cbf0d01dea7f7da0d04cb96dcb40e056e7d6e036d7b6e786c02b3b0dac30368ad86ca8305066e076d7b01000000000080e8eda1ba0100010080643addd0ec18010a8000000000100002010100";
const arg = Uint8Array.from(argHex.match(/../g) ?? [], (byte) => parseInt(byte, 16));
const { grant } = generateConsent({
resolved,
method: "icrc2_approve",
arg,
refs: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" },
canisterId: LEDGER, // the ledger called: the token the request grants
});
// 4. Does the request fit the terms?
const verdict = checkGrant(confirmed, grant, wallet);
console.log(JSON.stringify({ shown, grant, verdict }, null, 2));{
"shown": "Dex (bd3sg-teaaa-aaaaa-qaaba-cai) may spend at most 500 TKA, until the end of 30 November 2026 (UTC).",
"grant": {
"kind": "approve",
"spender": "bd3sg-teaaa-aaaaa-qaaba-cai",
"token": "bkyz2-fmaaa-aaaaa-qaaaq-cai",
"amount": {
"units": "50000000000",
"unlimited": false
},
"ends": {
"at": "1796040000"
}
},
"verdict": {
"fits": true,
"reasons": []
}
}
The request lets Dex spend 500 TKA (50000000000 base units) until 30 November 2026 at 12:00 UTC (1796040000
seconds), inside the terms, so the wallet may send it without asking Alice again. The terms cover this one request:
the next task starts with new terms. A wallet keeps the steps in this order with termsGate
(below). Each step has its section; every export is on the
JavaScript API reference.
Read the agent's terms#
The agent turns the person's words into four terms, the arguments of a tool the wallet gives it. Terms are what the
person allows one task: the token, who may take it, the most it may be (or unlimited) and the latest end (or never).
readTerms(fields, wallet) reads them in the wallet's own values:
| Field | What the agent writes | What readTerms makes of it |
|---|---|---|
token | A symbol the wallet knows: "TKA" | That token's canister id or EIP-55 address, with its symbol and decimals, from wallet.tokens |
spender or to, exactly one | An app's name as the wallet knows it ("Dex"), or an address or account the person gave | The app's account or address from wallet.apps; an address or account given is checked, and named when the wallet knows it |
max_amount | "500", "0.5", "500 TKA", or "unlimited" | Base units as a decimal string, never rounded |
expires_by | "2026-11-30", "30 days", "2 weeks", "1 month", "1 year", "2 hours", or "never" | The latest end in exact seconds: a date allows its whole day in the person's zone, a month is a calendar month, hours are an exact time from now |
spender is who may spend the tokens: an allowance, or a signature someone else submits. to
is who is paid directly. "unlimited" and "never" are read only as those words. The wallet
argument is { tokens, apps, now, utcOffsetMinutes }: now is the wallet's clock in
nanoseconds, as a bigint, and utcOffsetMinutes the person's zone, UTC when left out. The
functions have no clock or zone of their own.
Nothing is guessed. A term the wallet cannot read throws a TermsError whose message is
written for the agent, which fixes what it can and asks the person about the rest. With the wallet above:
| The agent wrote | code | message |
|---|---|---|
no expires_by | EE_TERMS_MISSING | missing parameter: expires_by |
token: "TKB" | EE_TERMS_TOKEN | the wallet knows no token named "TKB"; it knows TKA. If the user named another token, or did not say which, ask the user |
spender: "SwapCo" | EE_TERMS_PARTY | the wallet knows no app named "SwapCo"; it knows Dex. If the user named another app, or none, ask the user which app they mean, or for its account |
max_amount: "500.000000001" | EE_TERMS_AMOUNT | max_amount: "500.000000001" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8 |
expires_by: "end of the month" | EE_TERMS_END | expires_by must be a date as YYYY-MM-DD, a length of time from now as a number and a unit (hours, days, weeks, months or years), such as "2 hours", "30 days" or "1 month", or "never"; got "end of the month" |
expires_by: "2026-10-31" | EE_TERMS_END | expires_by 2026-10-31 is before today, 2026-11-02 |
A wallet argument that is not one (a now that is a number, not a bigint; two tokens with one
address) is the caller's mistake, not the agent's: E_REQUEST.
fact Observed 2026-10-29 in the everyday-wording test of Meant's signing guard (spec §11 item 10):
small models that wrote the terms from a user's everyday words filled a term the user had left out with the
loosest value ("unlimited", "never"), and a check against those terms passed requests the user never asked for;
the check itself held every time. So the agent asks for a missing term instead of filling it in, and the person's
confirmation of the exact text is what catches one it made up: readTerms cannot tell a "never" the
person said from one the agent wrote.
Show the terms on the wallet's own screen#
Whoever confirms terms authorizes every request that fits them, so the person confirms them on the wallet's own
screen, never through a tool the agent can call (spec §11 item 10). termsText(terms) writes them
exactly: the full address or account, the amount without rounding, and "unlimited" or "never ends" written out. For
the terms above, the wallet shows:
The same function for other terms, as the package writes them:
| Terms | termsText |
|---|---|
expires_by: "2 hours" | Dex (bd3sg-teaaa-aaaaa-qaaba-cai) may spend at most 500 TKA, until 2 November 2026 at 14:00 (UTC). |
max_amount: "unlimited", expires_by: "never" | Dex (bd3sg-teaaa-aaaaa-qaaba-cai) may spend any amount of TKA (unlimited), and this permission never ends. |
utcOffsetMinutes: 60 in the wallet | Dex (bd3sg-teaaa-aaaaa-qaaba-cai) may spend at most 500 TKA, until the end of 30 November 2026 (UTC+01:00). |
On Anvil, to: "Bob", max_amount: "15", expires_by: "2 hours" | Bob (0x70997970C51812dc3A010C7d01b50e0d17dc79C8) may be sent at most 15 TKA, until 2 November 2026 at 14:00 (UTC). |
When the person confirms that text, confirmTerms(terms) returns the terms frozen, with
id: the SHA-256 of their canonical JSON (RFC 8785), lowercase hex,
18c0e36c219f1afd8f69a63dfc1f97f93a18ab6531c2f206f770fc31afd8c115 for Alice's. A copy changed
afterwards is refused: checkGrant and termsText throw E_REQUEST,
terms are not the ones the user confirmed: they changed after confirmTerms: they come from readTerms, then
confirmTerms. checkGrant refuses terms that were never confirmed with E_REQUEST
too.
Read what the request grants#
The wallet does not take the agent's word for what a request does. It reads the request's grant from
the consent function that writes the request's text: the profile's effect names the positions
(intent, effects and confirmation), and the function reads their values from
this call or signature. The grant says who may take tokens because of the request, which token, how many and until
when:
| Key | Value |
|---|---|
kind | "approve" or "transfer" |
spender or to | An EIP-55 address, or an account in its ICRC-1 text |
token | An EIP-55 address, or a canister id in its canonical text |
amount | { units, unlimited }: base units as a decimal string, null for an unlimited grant without a count (DAI's allowed: true); "0" grants nothing |
ends | { at } in exact seconds as a decimal string, never rounded; { never: true }; or { now: true } for a transfer, made when it is sent |
How the wallet gets it depends on the chain:
generateConsent reads the grant only when you pass canisterId, the canister called, which
is the token. Without it, grant is null and nothing warns, and
icrc21ConsentMessage, the ICRC-21 call, answers as before, with no grant. Beside the message the result
has values, the decoded arguments, one value tree per argument. An icrc1_transfer of 1.5
TKA to Bob on the same ledger grants:
{
"kind": "transfer",
"to": "ig6pt-bilfj-ewrb5-gyxsa-gisbm-b7z5p-o47mn-dswdx-s225j-4ysgf-ig6",
"token": "bkyz2-fmaaa-aaaaa-qaaaq-cai",
"amount": { "units": "150000000", "unlimited": false },
"ends": { "now": true }
}
An icrc2_approve whose expires_at is left out never ends: { "never": true }.
evmConsent and eip712Consent in @meantjs/consent/evm always read the grant.
Its token is the contract called, or the verifying contract of a signature, unless the amount's tag names a field
that holds the token, as Permit2's do. Beside the text the result has values, the decoded arguments
or message by name. An ERC-20 approve of 500 TKA to Dex, on a token at
0x5FbD…0aa3 on Anvil chain 31337, grants:
{
"kind": "approve",
"spender": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
"token": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"amount": { "units": "500000000", "unlimited": false },
"ends": { "never": true }
}
An approve has no end among its arguments, so it never ends. EIP-2612's permit grants the
same: its deadline bounds only when the signature may be used, so it is not an end of what it grants
(spec §5.3). How a message names its grant: EVM contracts.
Check the grant against the terms#
checkGrant(confirmed, grant, wallet) returns { fits, reasons }. A grant fits when, by the
rules of spec §5.3:
- its token and its
spenderortoare the terms', compared as values: an address by its 20 bytes, an account by its owner and its subaccount, the default subaccount equal to an absent one; - its amount is at most the terms' most; unlimited fits only unlimited terms, and a grant of nothing fits any terms;
- it ends no later than the terms' latest end; one that never ends fits only terms that never end, and a transfer fits terms whose end has not passed.
Alice's terms against four icrc2_approve requests:
| The request | reasons |
|---|---|
| 500 TKA to Dex, until 30 November 2026 at 12:00 UTC | none: fits |
| 600 TKA to Dex, the same end | the request allows 600 TKA, the terms say at most 500 TKA |
500 TKA to Dex, no expires_at | the permission never ends, the terms say until the end of 30 November 2026 (UTC) |
500 TKA to be2us-…-qaabq-cai | the spender is be2us-64aaa-aaaaa-qaabq-cai, the terms say Dex (bd3sg-teaaa-aaaaa-qaaba-cai) |
A payment is checked the same way. On Anvil, terms that let Bob be sent at most 15 TKA for two hours, against an
ERC-20 transfer of 15 TKA to him:
| The request | reasons |
|---|---|
The transfer, at 12:00 UTC | none: fits |
The same transfer, at 15:00 UTC | the terms have ended: they said until 2 November 2026 at 14:00 (UTC) |
An approve of 15 TKA to Bob instead | the request lets Bob (0x70997970C51812dc3A010C7d01b50e0d17dc79C8) spend, the terms pay Bob (0x70997970C51812dc3A010C7d01b50e0d17dc79C8) directly, the permission never ends, the terms say until 2 November 2026 at 14:00 (UTC) |
A request that does not fit is refused with its reasons. The agent does not offer it to the person for a yes: it asks the person for new terms, and new terms are a new task.
A request without a grant needs its confirmation#
Terms cover only what a grant says. Given null, checkGrant answers
{ fits: false } with the reason
the request has no grant a profile describes, so no terms cover it: it needs the user's confirmation.
The person confirms such a request on its own, as every transfer, approve,
admin and destroy call is confirmed without terms. A request has no grant when:
- its profile's
effectcarries none: ERC-20transferFromand ICRC-2icrc2_transfer_fromin the packs, which spend an allowance someone else gave, and any method whose profile names no grant; - it grants several tokens: Permit2's
PermitBatchandPermitBatchTransferFrom, since a grant covers one token in v0; generateConsentwas not givencanisterId;-
its grant cannot be read, which WW_GRANT says. An
icrc1_transferto an account whose subaccount is 2 bytes long has the diagnosticgrant to: a subaccount is 32 bytes, got 2; the request has no grant, so terms the user confirmed cannot cover it. The text of the message is the same with or without a grant.
Keep the order with termsGate#
The terms must be confirmed before the agent reads the request, so that the request cannot shape them, and they cannot
change after. termsGate() keeps that order for one task. Here the task is Dex's EIP-2612 permit on
Anvil, which the wallet signs, and Alice has said plainly that the allowance need not end, since a permit's
allowance never does. tka.abi.json is the token's ABI as on EVM contracts,
with EIP-2612's permit, nonces and DOMAIN_SEPARATOR:
npx candid-profile resolve --abi tka.abi.json --packs > tka.resolved.jsonimport { readFileSync } from "node:fs";
import { eip712Consent } from "@meantjs/consent/evm";
import { readTerms, termsGate } from "@meantjs/consent/terms";
const TKA = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; // a token with EIP-2612's permit, on a local Anvil chain
const wallet = {
tokens: { TKA: { address: TKA, decimals: 6 } },
apps: { Dex: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" },
now: BigInt(Date.parse("2026-11-02T12:00:00Z")) * 1_000_000n,
};
const gate = termsGate(); // one gate per task
const shown = gate.propose(readTerms({ token: "TKA", spender: "Dex", max_amount: "500", expires_by: "never" }, wallet));
// The wallet shows `shown` on its own screen. When the person confirms it:
gate.confirm(shown);
gate.read(); // only now may the agent read the request
const resolved = JSON.parse(readFileSync("tka.resolved.json", "utf8")); // the ERC-20 and EIP-2612 packs apply
const request = eip712Consent({
resolved,
typedData: {
types: {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: { name: "Token A", version: "1", chainId: 31337, verifyingContract: TKA },
message: {
owner: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
spender: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
value: "500000000",
nonce: "0",
deadline: "1793622600",
},
},
chainId: 31337, // the wallet's active chain
refs: { "query:decimals": 6, "query:symbol": "TKA" },
});
const verdict = gate.check(request.grant, wallet, request.digest); // the digest names the request
shown is Dex (0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0) may spend at most 500 TKA, and this
permission never ends., the request's digest is
0x2c2508888c143ec60cf4e3d7df48cd0e02972246ee3ba7163baf8f254c765294, and verdict is
{ fits: true, reasons: [] }. The gate moves from "open" to "proposed",
"confirmed" and "read":
propose(terms)takes the agent's draft and returns the text to show. A new draft replaces the old one and its confirmation.confirm(shown)takes the text the person confirmed on the wallet's screen, which must be exactly the draft's, and returns the confirmed terms.reject()drops the draft when the person says no.read()comes before the agent reads the request, and is refused until the person has confirmed.check(grant, wallet, requestId)ischeckGrantfor the request read.
A step out of order throws TermsError EE_TERMS_ORDER, with a message for the agent:
| Step | message |
|---|---|
read() on a new gate | there are no terms yet: propose the user's terms, and read the request once the user confirms them |
read() after propose | the user has not confirmed the terms yet; the request can be read once they do |
confirm() with another text | the text the user confirmed is not the text of the terms proposed; show termsText(gate.proposed) and ask again |
propose() after read() | the request was read under the confirmed terms, so they cannot be changed now; new terms start a new task |
check() before read() | read the request first: its grant is checked against the terms it was read under |
One task, one request#
Terms cover one task: the request the agent was working on when the person confirmed them, and no other. A request is
named by what is signed or sent, not by its grant. check takes that name: eip712Consent's
digest for a signature, or the wallet's own id of a call (an EVM transaction's hash, an Internet Computer
request's id). The same request may be checked again; another is refused even when it grants the same.
fact Two requests can grant the same and each be spent, as two Permit2 transfers that differ only in
their nonce do (spec §5.3). The same permit with nonce: "1" has the same grant and the digest
0xbc5bb19c82bc8f4eb547ba6124340581f2838791a95da2690286904ff9b4843b; checked on the gate above, after
the first, it throws EE_TERMS_ORDER:
these terms cover one request, the one already checked; another request is a new task, with new terms.
A new task gets a new gate and the person's confirmation of its terms.
What terms do not cover#
- A grant is the profile's claim, like any effect (spec §11 item 1), and terms cover what a grant says. A request outside its grant's words, another call or another chain, is checked by the rest of the wallet, as without terms: the contract and chain an EVM consent text ends with, the deployments a profile lists, the decoded arguments shown beside the intent (security model).
- Terms are not a budget: they cover one request of one task. open Spending policies and budgets that would let an agent commit many calls without a confirmation each are OQ-8 on Open questions.
-
The person confirms; the model never does. No tool the agent can call may confirm terms.
@meantjs/toolshas no terms of its own: it awaits the host'sconfirm(preview)before every send (Give an agent tools).
Next#
The effect tag's grant: the positions a profile names, and their rules.
The consent functions that return a request's values and grant beside its text.
Why confirmed terms authorize in advance, and who may confirm them.