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#

bash
npm install --save-exact @meantjs/consent@0.5.0 @meantjs/profile@0.5.0
npx candid-profile resolve ledger.did --packs > ledger.resolved.json
js
import { 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));
json
{
  "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:

FieldWhat the agent writesWhat readTerms makes of it
tokenA 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 oneAn app's name as the wallet knows it ("Dex"), or an address or account the person gaveThe 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 wrotecodemessage
no expires_byEE_TERMS_MISSINGmissing parameter: expires_by
token: "TKB"EE_TERMS_TOKENthe 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_PARTYthe 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_AMOUNTmax_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_ENDexpires_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_ENDexpires_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.

A term left out is not a loose term

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:

TermstermsText
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 walletDex (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:

KeyValue
kind"approve" or "transfer"
spender or toAn EIP-55 address, or an account in its ICRC-1 text
tokenAn 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:

json
{
  "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:

json
{
  "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 spender or to are 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 requestreasons
500 TKA to Dex, until 30 November 2026 at 12:00 UTCnone: fits
600 TKA to Dex, the same endthe request allows 600 TKA, the terms say at most 500 TKA
500 TKA to Dex, no expires_atthe permission never ends, the terms say until the end of 30 November 2026 (UTC)
500 TKA to be2us-…-qaabq-caithe 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 requestreasons
The transfer, at 12:00 UTCnone: fits
The same transfer, at 15:00 UTCthe terms have ended: they said until 2 November 2026 at 14:00 (UTC)
An approve of 15 TKA to Bob insteadthe 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 effect carries none: ERC-20 transferFrom and ICRC-2 icrc2_transfer_from in the packs, which spend an allowance someone else gave, and any method whose profile names no grant;
  • it grants several tokens: Permit2's PermitBatch and PermitBatchTransferFrom, since a grant covers one token in v0;
  • generateConsent was not given canisterId;
  • its grant cannot be read, which WW_GRANT says. An icrc1_transfer to an account whose subaccount is 2 bytes long has the diagnostic grant 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:

bash
npx candid-profile resolve --abi tka.abi.json --packs > tka.resolved.json
js
import { 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) is checkGrant for the request read.

A step out of order throws TermsError EE_TERMS_ORDER, with a message for the agent:

Stepmessage
read() on a new gatethere are no terms yet: propose the user's terms, and read the request once the user confirms them
read() after proposethe user has not confirmed the terms yet; the request can be read once they do
confirm() with another textthe 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/tools has no terms of its own: it awaits the host's confirm(preview) before every send (Give an agent tools).

Next#