Profiles

JavaScript API

The exports of @meantjs/profile, @meantjs/format, @meantjs/consent, @meantjs/tools and @meantjs/reconcile.

The five @meantjs packages are plain ES modules with hand-written TypeScript declarations (src/*.d.mts), one per exported entry. Every signature on this page is quoted verbatim from those files, and the build checks each quote against the file it names. The guides show the same functions in use: Validate and resolve, Parse and display values, Consent messages, Agent tools and Settle an unknown outcome.

Install#

bash
npm install @meantjs/profile@0.5.0 @meantjs/format@0.5.0 \
  @meantjs/consent@0.5.0 @meantjs/tools@0.5.0 @meantjs/reconcile@0.5.0
PackageVersionDepends on (exact pins)Runs on
@meantjs/profile0.4.0@candid-core/cli 0.4.0, ajv 8.18.0Node 22 or later for the main entry and the CLI; /browser in a browser bundle; /address and /evm anywhere, with no dependency
@meantjs/format0.4.0noneNode 22 or later and browsers
@meantjs/consent0.4.0@candid-core/schema 0.5.0, @meantjs/format 0.4.0, @meantjs/profile 0.4.0Node 22 or later and browsers, /terms included; /sns is Node only (it hashes with node:crypto)
@meantjs/tools0.4.0@meantjs/format 0.4.0Node 22 or later and browsers
@meantjs/reconcile0.4.0@meantjs/format 0.4.0Node 22 or later and browsers

Each name links to its source under profile/packages/; @meantjs/profile is packages/resolver.

fact 0.4.0 was published to npm on 2026-10-30, all five under latest and each with signed provenance (the npm registry, read 2026-10-30). It adds what a call grants (spec §5.3, D-056): the grant of an effect in @meantjs/profile and its packs, the grant and values the consent functions return, and the new subpath @meantjs/consent/terms. @meantjs/format, @meantjs/tools and @meantjs/reconcile did not change in 0.4.0: their changelogs say "No changes in this package", and only their version and pins moved. Each package's CHANGELOG.md lists what changed; 0.3.0 (2026-10-29) added EIP-712 typed messages (spec §15.9, D-055, experimental).

One version for all five

The five packages release together under one version and pin each other exactly ("0.4.0", not "^0.4.0"), so an install never mixes versions that were not tested together. A release bumps all five even when only one changed. While the major is 0 the minor is the breaking digit, and any change to text the packages generate (consent messages, SNS renderings, tool names, descriptions, input schemas) is a breaking change. The procedure is profile/docs/releasing.md.

All entries are ES modules only. An EVM resolved form ("chain": "evm") is experimental, spec §15: see EVM contracts.

@meantjs/profile#

Validates profiles, matches packs to a service, merges layers and emits the resolved form. Also the candid-profile command (The candid-profile command).

@meantjs/profile (main entry)#

The Node entry: the functions shared with /browser (next block), plus the ones that read files and the CLI.

tsprofile/packages/resolver/src/index.d.mts
export const DEFAULT_SCHEMA_PATH: string;
export const DEFAULT_PACKS_DIR: string;
export function createSchemaValidator(schema?: string | Record<string, unknown>): Promise<SchemaValidator>;
export function loadPacks(dir?: string): Promise<Pack[]>;
export function runCli(argv: string[], io: { stdout: { write(text: string): unknown }; stderr: { write(text: string): unknown } }): Promise<0 | 1 | 2>;
export const USAGE: string;
tsprofile/packages/resolver/src/browser.d.mts
export const LAYERS: readonly ["author", "third_party", "pack", "inferred"];
export function resolveService(opts: ResolveOptions): Promise<ResolveResult>;
export function validateProfile(doc: unknown, opts: { validateSchema: SchemaValidator; did?: string | null; didLabel?: string; resolved?: boolean }): Promise<ValidateResult>;
export function idlHash(label: string): number;
export function jcs(value: unknown): string;
export function profileId(doc: unknown): string;
export function parseAddress(at: string): Address | null;
export function parseRelPath(rel: string): { index: number | null; segs: PathSegment[] } | null;
export function compileInterface(source: string, label?: string): Promise<{ iface: Iface } | { failure: { code: string; message: string }[] }>;
export function interfaceFromEnvelope(envelope: unknown, label?: string): { iface: Iface } | { failure: { code: string; message: string }[] };
export function matchPack(canister: Iface, pack: unknown, opts?: Record<string, unknown>): Promise<{ matched: boolean; reasons: { at: string; message: string }[]; relaxed: unknown; fragment: Iface } | { error: { code: string; message: string }[] }>;
export function matchService(canister: Iface, fragment: Iface, opts?: Record<string, unknown>): { ok: boolean; reasons: { at: string; message: string }[]; relaxed: unknown };
export function typeSubtype(S: Iface, s: unknown, T: Iface, t: unknown, opts?: Record<string, unknown>): unknown;
export function positions(iface: Iface, methodName: string, side: "args" | "results", maxDepth?: number): Generator<Position>;
export function analyzeProfiles(iface: Iface, items: unknown[], opts?: { attribute?: boolean }): { diagnostics: Diagnostic[]; layered: LayeredTags; ordered: unknown[]; stale: Set<unknown> };
export function resolvedForm(iface: Iface, profiles: unknown[], tags: LayeredTags, diagnostics: Diagnostic[]): ResolvedForm;
export function buildTree(iface: Iface, methodName: string, side: "args" | "results", tags: LayeredTags): unknown;
export const DOC_ATTRIBUTES_VERSION: 1;
export function profileFromDocAttributes(
  response: unknown,
  opts?: { name?: string; origin?: "author" | "third_party"; language?: string; canisterId?: string },
): { ok: boolean; doc: ProfileDocument | null; diagnostics: DocDiagnostic[] };
export const SERVED_SECTION: "icp:public candid:profile";
export const SERVED_METADATA: "candid:profile";
export const SERVED_MAX_BYTES: number;
export function readServedProfile(
  bytes: Uint8Array,
  opts: { canisterId: string | null; label?: string },
): { ok: boolean; doc: ProfileDocument | null; diagnostics: Diagnostic[] };
ExportDoes
createSchemaValidator(schema?)The validate function for the profile schema: a path or a document, by default the schema the package ships. The shipped schema is compiled ahead of time; any other is compiled by ajv at run time with new Function.
resolveService(opts)Resolves one service (did, envelope or abi, exactly one) with profiles (applied as given; a pack here must match) and packs (tried; applied only if they match). Returns { ok, resolved, diagnostics, profiles }; resolved is null when the service does not compile.
validateProfile(doc, opts)The single-profile validator that scripts/validate.mjs wraps: { ok, profile_id, interface_id, methods, annotations, diagnostics, resolved? }.
loadPacks(dir?)Every *.profile.json of kind "pack" in a directory, sorted by file name (default: the shipped packs, DEFAULT_PACKS_DIR). Packs only: the two standard contract profiles shipped in the same directory, permit2 and dai-permit, are not returned.
profileId(doc), jcs(value)candid-profile:v0:sha256:<hex> over the RFC 8785 (JCS) form; the JCS text itself.
profileFromDocAttributes(response, opts?)An author canister profile from the tags written as doc comments in a .did; response is what didToDocAttributes of @candid-core/cli returns (Annotate a .did file).
readServedProfile(bytes, opts), SERVED_SECTION, SERVED_METADATA, SERVED_MAX_BYTESChecks the bytes of a canister's candid:profile metadata; EE_SERVED when they cannot be used (Serve a profile).
runCli(argv, io), USAGEcandid-profile as a function, returning its exit code; its help text.
parseAddress, parseRelPath, idlHashThe address grammar and the Candid field-name hash (also in /address).
compileInterface, interfaceFromEnvelope, IfaceAn interface as the resolver sees it: the Contract graph with lookups.
matchPack, matchService, typeSubtype, SubtypeCheckerPack matching by Candid subtyping, result variants allowed to be wider (D-019).
analyzeProfiles, resolvedForm, buildTree, positions, TagIndex, LayeredTags, LAYERSThe steps of a resolve: index and merge the layers, then write the tree.

The types a caller handles most, from the same file:

tsprofile/packages/resolver/src/browser.d.mts
export interface ProfileStatus {
  name: string;
  origin: string | null;
  kind: string | null;
  profile_id: string | null;
  status: "applied" | "rebound" | "stale" | "not_matched" | "invalid";
  explicit: boolean;
}
// …
export interface ResolveOptions {
  /** Candid source of the service (exactly one of `did`, `envelope` and `abi`). */
  did?: string;
  /** The service's candid-core ContractEnvelope. */
  envelope?: unknown;
  /** An EVM contract's ABI: human-readable fragments or JSON ABI entries (section 15). */
  abi?: readonly (string | Record<string, unknown>)[];
  didLabel?: string;
  /** Applied as given; a pack here must match. */
  profiles?: { doc: unknown; label?: string }[];
  /** Tried by Candid subtyping (an EVM pack by selector); applied only if they match. */
  packs?: { doc: unknown; label?: string }[];
  validateSchema: SchemaValidator;
}

export interface ResolveResult {
  ok: boolean;
  /** null when the service does not compile (or the envelope cannot be read). */
  resolved: ResolvedForm | null;
  diagnostics: Diagnostic[];
  profiles: ProfileStatus[];
}

Other exported types: ProfileDocument, ResolvedForm, Diagnostic, DocDiagnostic, Pack, SchemaValidator, ValidateResult, Address, PathSegment, ContractNode, Position. The fields of the documents are on Profile and resolved form, and the codes on Profile diagnostics.

js
import { createSchemaValidator, loadPacks, resolveService } from "@meantjs/profile";
import { readFileSync } from "node:fs";

const validateSchema = await createSchemaValidator();
const packs = (await loadPacks()).map(({ doc }) => ({ doc }));
const r = await resolveService({ did: readFileSync("ledger.did", "utf8"), validateSchema, packs });
for (const p of r.profiles) console.log(p.status, p.name, p.profile_id);

@meantjs/profile/address#

The address grammar (spec §3) and the Candid field-name hash, with no dependency, for a browser bundle.

tsprofile/packages/resolver/src/address.d.mts
export interface PathSegment {
  kind: "field" | "elem";
  /** The field name, or `#<id>` for a numeric id. */
  label?: string;
  id?: number;
}

export type Address =
  | { kind: "type"; decl: string; segs: PathSegment[] }
  | { kind: "arg" | "result"; method: string; index: number; segs: PathSegment[] }
  | { kind: "method"; method: string; segs: [] };

/** The largest Candid field id. */
export const U32_MAX: 4294967295;
/** The Candid field-name hash. */
export function idlHash(label: string): number;
/** `.amount.e8s`, `.#5`, `[]` to segments; null when the path does not parse. */
export function parsePath(path: string): PathSegment[] | null;
/** `send(0).to`, `balance->(0)`, `type:Tokens.e8s` or a method name; null when the address does not parse. */
export function parseAddress(at: string): Address | null;
/** A path relative to a method: `(0).to.owner`, `to.owner` or `Ok`; `index` is null without `(n)`. */
export function parseRelPath(rel: string): { index: number | null; segs: PathSegment[] } | null;
/** The resolver's key of a method position. */
export function methodKey(method: string, side: "arg" | "res", index: number, segs: PathSegment[]): string;
/** The resolver's key of a `type:` edge. */
export function edgeKey(parent: { container: number; kind: string; id?: number }): string;
js
import { parseAddress, idlHash } from "@meantjs/profile/address";

const a = parseAddress("icrc1_transfer(0).amount");
if (a !== null && a.kind === "arg") console.log(a.method, a.index, a.segs.map((s) => s.label));
idlHash("amount"); // the u32 a Contract stores for the field

@meantjs/profile/browser#

The whole resolver for a browser bundle: the main entry's functions without the ones that read files (loadPacks, the CLI, the default paths), plus the shipped data and the CLI's wasm loader. The cli's wasm needs 'wasm-unsafe-eval' in a Content-Security-Policy; the shipped schema needs no 'unsafe-eval'.

tsprofile/packages/resolver/src/browser.d.mts
export const PROFILE_SCHEMA: Record<string, unknown>;
export const STANDARD_PACKS: readonly Pack[];
export function initCompiler(input?: BufferSource | URL | string | Response): Promise<void>;
export function createSchemaValidator(doc?: Record<string, unknown>): Promise<SchemaValidator>;
export function schemaDiagnostics(validateSchema: SchemaValidator): Diagnostic[];
ExportDoes
PROFILE_SCHEMA, STANDARD_PACKSThe profile schema and the four standard packs (erc20, erc2612, icrc1, icrc2), as data, in the shape loadPacks returns. The contract profiles are not in it; they are under ./packs/*.
initCompiler(input?)Optional: initializes the cli's wasm compiler, for a bundler that moves the wasm file.
createSchemaValidator(doc?)A document, never a path; by default the shipped schema.
schemaDiagnostics(validateSchema)The last schema errors as EE_SCHEMA diagnostics.
everything elseAs in the main entry, from the same declarations.

@meantjs/profile/evm#

The EVM helpers of spec §15 (experimental), with no dependency: ABI fragments, a strict codec, EIP-55 addresses, and EIP-712 types, type hashes and digests (§15.9). Every failure is an AbiError: EE_ABI, EE_ABI_ENCODE, EE_ABI_DECODE, EE_EIP712, EE_ADDRESS or EE_ADDRESS_CHECKSUM.

tsprofile/packages/resolver/src/evm.d.mts
export interface AbiFunction {
  name: string;
  inputs: AbiParam[];
  outputs: AbiParam[];
  stateMutability: "pure" | "view" | "nonpayable" | "payable";
  signature: string;
  typeSignature: string;
  selector: string;
  canonical: string;
  at?: string;
}
export type AbiValue = string | bigint | boolean | Uint8Array | AbiValue[] | { [key: string]: AbiValue };
export declare class AbiError extends Error {
export function keccak256(input: Uint8Array | string): Uint8Array;
export function keccak256Hex(input: Uint8Array | string): string;
export function jcs(value: unknown): string;
export const DEFAULT_ABI_LIMITS: Readonly<Required<AbiLimits>>;
export function parseAbi(entries: unknown, label?: string): { functions: AbiFunction[]; errors: { at: string; message: string }[] };
export function parseSignature(signature: string, where?: string): AbiFunction;
export function abiInterfaceId(functions: readonly AbiFunction[], types?: Eip712Types): string;
export function canonicalFragment(f: Pick<AbiFunction, "name" | "inputs" | "outputs" | "stateMutability">): string;
export function namedSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
export function typeSignature(f: Pick<AbiFunction, "name" | "inputs">): string;
export function selectorOf(f: Pick<AbiFunction, "name" | "inputs">): string;
export function typeString(type: AbiType, names?: boolean): string;
export function componentKey(param: AbiParam, index: number): string;
export function encodeParams(params: readonly AbiParam[], values: Record<string, AbiValue>): Uint8Array;
export function encodeCall(f: AbiFunction, values: Record<string, AbiValue>): Uint8Array;
export function decodeParams(params: readonly AbiParam[], data: Uint8Array, limits?: AbiLimits): Record<string, AbiValue>;
export function toChecksumAddress(address: string): string;
export function readAddress(text: string): string;
export function typedDataDigest(typed: TypedData): string;
export type Eip712Types = Record<string, { name: string; type: string }[]>;
export function parseEip712Types(types: unknown, label?: string): { types: Eip712Types; errors: { at: string; message: string }[] };
export function encodeType(types: Eip712Types, primary: string): string;
export function eip712TypeHash(types: Eip712Types, primary: string): string;
ExportDoes
parseAbi(entries, label?)Reads an ABI (human-readable fragments or JSON ABI entries); only functions. Problems are listed, not thrown.
parseSignature(signature)One function from its named signature, as a resolved form carries it. Throws.
abiInterfaceId(functions, types?)candid-profile:evm-abi:v0:sha256:…: SHA-256 over the JCS array of the canonical fragments and, when given EIP-712 types, one struct Name(type member,…) line per type, sorted. Without types (or with {}) it is the phase 1 identity, unchanged.
parseEip712Types(types, label?)Reads EIP-712 struct types: identifier names that are not ABI types or EIP712Domain, unique member names, canonical member types (uint256, never uint) or structs of the same map, and no struct that contains itself. Problems are listed, not thrown; types keeps the structs that read.
encodeType, eip712TypeHashEIP-712 encodeType (the struct with its members, then the structs it refers to, sorted by name) and its Keccak-256, as 0x and 64 hex digits. A primary type not in the map throws EE_EIP712.
canonicalFragment, namedSignature, typeSignature, selectorOf, typeString, componentKeyfunction transfer(address to,uint256 value) returns (bool); transfer(address to,uint256 value); transfer(address,uint256); 0xa9059cbb; a type's canonical text; a tuple component's key (its name, else # and its position).
encodeParams, encodeCall, decodeParamsThe strict codec: a value read from anything but its canonical encoding is refused.
toChecksumAddress, readAddressThe EIP-55 form; reading an address whose mixed case must match it.
keccak256, keccak256Hex, typedDataDigest, jcsKeccak-256 (Ethereum's, not NIST SHA3-256); the EIP-712 digest; RFC 8785 JSON.

EIP-712's own Mail example, read and hashed:

js
import { eip712TypeHash, encodeType, parseEip712Types } from "@meantjs/profile/evm";

const { types, errors } = parseEip712Types({
  Mail: [
    { name: "from", type: "Person" },
    { name: "to", type: "Person" },
    { name: "contents", type: "string" },
  ],
  Person: [
    { name: "name", type: "string" },
    { name: "wallet", type: "address" },
  ],
});
errors; // []
encodeType(types, "Mail"); // "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
eip712TypeHash(types, "Mail"); // "0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2"
parseEip712Types({ Short: [{ name: "x", type: "uint" }] }).errors;
// [{ at: "types.Short[0]", message: "write uint256, not uint: the type hash hashes the type as written" }]

@meantjs/profile/schema.json and @meantjs/profile/packs/*#

JSON documents, not modules: ./schema.json is the package's copy of spec/profile-v0.schema.json, and ./packs/* its copies of the six standard files of profile/packs/: the packs icrc1.profile.json, icrc2.profile.json, erc20.profile.json and erc2612.profile.json, and the EVM contract profiles permit2.profile.json and dai-permit.profile.json (experimental, §15.9). A contract profile is never tried as a pack: pass it in profiles. Import them with an import attribute, and pass them as documents:

jsprofile/packages/resolver/README.md
import schema from "@meantjs/profile/schema.json" with { type: "json" };          // copy of spec/profile-v0.schema.json
import icrc1 from "@meantjs/profile/packs/icrc1.profile.json" with { type: "json" };
const validateSchema = await createSchemaValidator(schema);                            // a document, not a path
const r = await resolveService({ did, validateSchema, packs: [{ doc: icrc1 }] });

The packs and their annotations are listed on Standard packs.

@meantjs/format#

Exact value conversion for the six value tags: text a person or an agent writes to the value a call carries, and back. No dependency, no I/O. Every rejection is a FormatError; nothing is rounded.

tsprofile/packages/format/src/index.d.mts
export type ValueTag = "amount" | "timestamp" | "duration" | "account" | "subaccount" | "bytes";
export type TimeUnit = "ns" | "us" | "ms" | "s";
export type BytesEncoding = "utf8" | "hex" | "base64";
export interface AmountOptions {
  decimals: number | bigint | string;
  symbol?: string;
  candid?: string;
  threshold?: string | bigint;
  message?: string;
}
export function parseAmount(input: string, options: AmountOptions): string;
export function displayAmount(value: string | bigint, options: AmountOptions): string;
export function convertUnit(value: string | bigint, from: TimeUnit, to: TimeUnit): string;
export function alignNow(nowNs: bigint, unit: TimeUnit): bigint;
export function parseTimestamp(input: string, options: { unit: TimeUnit; candid?: string; now?: bigint }): string;
export interface TimestampOptions {
  unit: TimeUnit;
  candid?: string;
  threshold?: string | bigint;
  message?: string;
  labels?: Record<string, string>;
}
export function displayTimestamp(value: string | bigint, options: TimestampOptions): string;
export function parseDuration(input: string, options: { unit: TimeUnit; candid?: string }): string;
export function displayDuration(value: string | bigint, options: { unit: TimeUnit; candid?: string }): string;
export function parseAccount(input: string): Account;
export function displayAccount(account: { owner: string | Uint8Array; subaccount?: ByteInput | null }): string;
export function principalFromText(text: string): Uint8Array;
export function principalToText(bytes: Uint8Array): string;
export function parseSubaccount(input: string): Uint8Array;
export function displaySubaccount(value: ByteInput): string;
export function parseBytes(input: string, options: { encoding: BytesEncoding; maxLen?: number }): Uint8Array;
export function displayBytes(value: ByteInput, options: { encoding: BytesEncoding }): string;
export function asUint8Array(value: unknown): Uint8Array | null;
export interface Formatter {
  readonly tag: ValueTag;
  readonly candid: string;
  readonly args: Readonly<Record<string, unknown>>;
  parse(input: string, context?: { now?: bigint }): ParsedValue;
  display(value: unknown): string;
  safeParse(input: string, context?: { now?: bigint }): SafeResult<ParsedValue>;
  safeDisplay(value: unknown): SafeResult<string>;
  describe(): Description;
}
export const VALUE_TAGS: readonly ValueTag[];
export function formatterFor(node: FormatNode, options?: { refs?: Record<string, unknown>; now?: bigint; token?: { decimals: number | bigint | string; symbol?: string } }): Formatter | null;
export function describeInput(tag: ValueTag, args: Readonly<Record<string, unknown>>, candid: string, options?: { accepts?: (input: string) => boolean }): Description;
export function normalizeName(text: string): string;
export function resolveName<V>(
  entries: NameEntry<V>[],
  input: string,
  opts?: { isValue?: (text: string) => boolean; kind?: (kind: unknown) => boolean; by?: "value" | "show" },
): NameResolution<V>;
export function ownerOf<V>(entries: NameEntry<V>[], value: unknown, opts?: { by?: "value" | "show" }): NameEntry<V> | undefined;
export function nameOf<V>(entries: NameEntry<V>[], value: V, opts?: { by?: "value" | "show"; show?: (value: unknown) => string }): string;
ExportDoes
formatterFor(node, options?)The formatter of a resolved-form position, or null when it has no value tag. Throws EE_REF_UNRESOLVED, EE_ARG, EE_TAG_TYPE or EE_TAG_CONFLICT rather than return one that half works. refs are keyed by from. token: for an amount whose token argument names its token by a field (EVM, §15.9), that token's decimals and symbol, which the caller reads; without it the amount is EE_REF_UNRESOLVED.
parseAmount, displayAmount"1.5 ICP" to base units as a decimal string, and back. A number is refused. threshold and message are display only: a value at or above the threshold displays as the message (default Unlimited) and the symbol.
parseTimestamp, displayTimestamp, parseDuration, displayDuration, convertUnit, alignNowRFC 3339 and ISO 8601 text to a count of the tag's unit, and back; exact unit conversion. TimestampOptions adds words, display only: a value labels names shows its word, then a value at or above threshold shows message, so that it is never read as a date. threshold and message come together, with no default word (EE_ARG). Parsing is unchanged; a word is never parsed back.
parseAccount, displayAccount, principalFromText, principalToTextThe ICRC-1 textual account encoding and principal text, checksums verified.
parseSubaccount, displaySubaccount, parseBytes, displayBytes, asUint8Array32-byte subaccounts (#n or 64 hex), bytes in the tag's encoding, and a realm-safe byte check.
describeInputFormatter.describe without a formatter: the summary, examples and pattern of a position's input.
normalizeName, resolveName, ownerOf, nameOfThe one name-matching rule of spec §5.3 (Names).
VALUE_TAGS, CODES, FormatErrorThe six value tags in spec order; the 23 codes (FormatError).
The model sees1.5 ICP
parseAmount("1.5 ICP", { decimals: 8, symbol: "ICP" })
The call sends150000000
"150000000"
js
import { parseAmount, displayAmount, FormatError } from "@meantjs/format";

parseAmount("1.5 ICP", { decimals: 8, symbol: "ICP" }); // "150000000"
displayAmount("150000000", { decimals: 8, symbol: "ICP" }); // "1.5 ICP"
try {
  parseAmount("1.123456789", { decimals: 8 });
} catch (e) {
  if (e instanceof FormatError) console.log(e.code); // "E_EXCESS_PRECISION": never rounded
}

Words for a deadline, and an amount in the token a field names, as Permit2's profile tags them:

js
import { displayTimestamp, formatterFor } from "@meantjs/format";

const expiration = { unit: /** @type {const} */ ("s"), candid: "uint48", threshold: "281474976710655", message: "Never", labels: { "0": "Only in the block it is used" } };
displayTimestamp("0", expiration); // "Only in the block it is used"
displayTimestamp("281474976710655", expiration); // "Never"
displayTimestamp("1798761600", expiration); // "2027-01-01T00:00:00Z"

const amount = { abi: "uint160", tags: { amount: { args: { token: "details.token", threshold: "1461501637330902918203684832716283019655932542975" } } } };
const f = formatterFor(amount, { token: { decimals: 6, symbol: "USDC" } });
f?.display("1500000"); // "1.5 USDC"
f?.display("1461501637330902918203684832716283019655932542975"); // "Unlimited USDC"
// formatterFor(amount) without `token` throws E_REF_UNRESOLVED

ICRC-21 consent messages for any method, from a resolved form and the Candid-encoded argument bytes; the text an SNS proposal's validator returns; experimental, consent text and ERC-7730 files for EVM calls and EIP-712 signing requests; and, since 0.4.0, what a request grants and the check of that grant against terms the person confirmed (/terms). No network code.

tsprofile/packages/consent/src/index.d.mts
export interface Icrc21Context {
  resolved: ResolvedForm;
  refs?: Record<string, unknown>;
  languagePolicy?: "fallback" | "error";
  rawArguments?: boolean;
  limits?: DecodeLimits;
}
export type Icrc21Result = Icrc21ConsentMessageResponse & { readonly diagnostics: ConsentDiagnostic[]; readonly provenance: Provenance };
export function icrc21ConsentMessage(request: Omit<Icrc21ConsentMessageRequest, "arg"> & { arg: Bytes }, context: Icrc21Context): Icrc21Result;
export function handleConsentRequest(requestBytes: Uint8Array, context: Icrc21Context): Uint8Array;
export function toIcrc21Error(error: unknown): Icrc21Error;
export const SUPPORTED_LANGUAGE: "en";
export function encodeConsentResponse(response: Icrc21ConsentMessageResponse): Uint8Array;
export function decodeConsentResponse(bytes: Uint8Array): Icrc21ConsentMessageResponse;
export function encodeConsentRequest(request: Icrc21ConsentMessageRequest): Uint8Array;
export function decodeConsentRequest(bytes: Uint8Array): Icrc21ConsentMessageRequest;
export function toJson(value: unknown): unknown;
export interface GenerateConsentInput {
  resolved: ResolvedForm;
  method: string;
  arg: Bytes;
  refs?: Record<string, unknown>;
  form?: "fields" | "generic";
  utcOffsetMinutes?: number | null;
  rawArguments?: boolean;
  limits?: DecodeLimits;
  canisterId?: string;
}
export function generateConsent(input: GenerateConsentInput): {
  form: "fields" | "generic";
  message: Icrc21ConsentMessage;
  zoneSensitive: boolean;
  values: unknown[];
  grant: Grant | null;
  diagnostics: ConsentDiagnostic[];
  provenance: Provenance;
  model: Model;
};
export function buildModel(input: { resolved: ResolvedForm; method: string; arg: Bytes; limits?: DecodeLimits }): Model;
export function decodeArguments(method: { args: unknown[] }, arg: Bytes, limits?: DecodeLimits): unknown[];
export function schemaFor(node: unknown, where?: string, depth?: number): unknown;
export function parseTemplate(template: string): TemplatePart[];
export function resolvePath(method: { args: unknown[] }, relpath: string): ResolvedPath | { error: string };
export function valuesAt(path: ResolvedPath, values: unknown[]): unknown[];
export declare class Renderer {
export function zoneName(offsetMinutes: number): string;
export function renderFields(model: Model, renderer: Renderer, options?: { rawArguments?: boolean }): { intent: string; fields: [string, Icrc21Value][] };
export function renderGeneric(model: Model, renderer: Renderer, options?: { rawArguments?: boolean; headingLevel?: number }): string;
export function codeSpan(s: string): string;
export function escapeMarkdown(s: string): string;
export function visible(s: string): string;
export declare class ConsentError extends Error {
ExportDoes
icrc21ConsentMessage(request, context)The ICRC-21 call as a function: { Ok: { consent_message, metadata } } or { Err }, with non-enumerable diagnostics and provenance (which profile wrote the words).
handleConsentRequest(requestBytes, context)Candid bytes of icrc21_consent_message_request in, Candid bytes of the response out: what a canister endpoint returns.
generateConsent(input)One form without the ICRC-21 envelope: "generic" (Markdown, the default) or "fields". Since 0.4.0 it also returns values, the decoded arguments as decodeArguments gives them, and grant, what the call grants (below), or null. It reads the grant only when canisterId names the canister called, the grant's token; without it grant is null with no diagnostic, so an ICRC-21 message is unchanged. A canisterId that is not a principal's text is EE_REQUEST.
encodeConsentRequest, decodeConsentRequest, encodeConsentResponse, decodeConsentResponse, toJsonThe Candid codec for the ICRC-21 types, and a JSON-safe copy (bigint as a decimal string, Uint8Array as { hex }).
toIcrc21Error, CODES, GENERIC_ERROR_CODES, ConsentError, SUPPORTED_LANGUAGEErrors and their ICRC-21 mapping (ConsentError; 0.3.0 adds DOMAIN and TYPE_HASH, used by /evm only); "en", the only language v0 emits.
buildModel, decodeArguments, schemaFor, parseTemplate, resolvePath, valuesAt, Renderer, zoneName, renderFields, renderGeneric, codeSpan, escapeMarkdown, visibleThe parts: decode, model, render and escape.

Since 0.3.0, a "fields" message shows an amount at or above its threshold, and a timestamp shown as words, as Text: never a TokenAmount or TimestampSeconds of the raw number (the 0.3.0 changelog of @meantjs/consent).

jsprofile/packages/consent/README.md
import { icrc21ConsentMessage, handleConsentRequest } from "@meantjs/consent";

const response = icrc21ConsentMessage(
  {
    method: "icrc1_transfer",
    arg: argBytes, // Uint8Array: the Candid-encoded argument tuple of the call
    user_preferences: { metadata: { language: "en", utc_offset_minutes: 120 }, device_spec: { GenericDisplay: null } },
  },
  {
    resolved, // fixtures/*.resolved.json shape, or the output of `candid-profile resolve`
    refs: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" }, // dynamic refs, resolved by the caller
  },
);

The grant the consent functions return, here and in /evm, is read from the grant of the method's or message's effect (spec §5.3, D-056; Tags) and the decoded values. It is null for a request with no grant, and for one whose grant cannot be read, which also adds a WW_GRANT diagnostic; the consent text is the same either way. Addresses are EIP-55, accounts their ICRC-1 text, and nothing is rounded:

tsprofile/packages/consent/src/index.d.mts
export interface Grant {
  kind: "transfer" | "approve";
  /** Who may take the tokens: an EIP-55 address, or an account in its ICRC-1 text. Exactly one of `spender` and `to`. */
  spender?: string;
  /** Who is paid directly: an EIP-55 address, or an account in its ICRC-1 text. */
  to?: string;
  /** The token: an EIP-55 address, or a canister id in its canonical text. */
  token: string;
  /** Base units as a decimal string; `null` when unlimited without a count (DAI's `allowed: true`). A grant of nothing is `"0"`. */
  amount: { units: string | null; unlimited: boolean };
  /** `at`: exact seconds since the Unix epoch as a decimal string, never rounded ("1767225600.123456789"). `now`: a transfer, made when it is sent. */
  ends: { at: string } | { never: true } | { now: true };
}

amount.unlimited is true at or above the amount tag's threshold, or for a bool amount that is true. ends is the latest of the grant's ends positions, { never: true } when one is at its timestamp tag's threshold or an optional one is left out, and, with no ends, { never: true } for an approve and { now: true } for a transfer (src/grant.mjs). Compare it with terms the person confirmed in /terms.

The governance counterpart of ICRC-21: an SNS generic-function validator generated from the profile. Node only.

tsprofile/packages/consent/src/sns.d.mts
export type ValidatorResult = { Ok: string } | { Err: string };
export const SNS_RENDERING_RULES: "candid-profile-sns/v0";
export function snsValidatorRendering(input: {
  payload: Bytes;
  resolved: ResolvedForm;
  method: string;
  refs?: Record<string, unknown>;
  limits?: DecodeLimits;
}): ValidatorResult & { readonly diagnostics: ConsentDiagnostic[]; readonly provenance: Provenance };
export function handleSnsValidatorCall(payload: Uint8Array, context: { resolved: ResolvedForm; method: string; refs?: Record<string, unknown>; limits?: DecodeLimits }): Uint8Array;
export function encodeValidatorResult(result: ValidatorResult): Uint8Array;
export function decodeValidatorResult(bytes: Uint8Array): ValidatorResult;
export function parseGovernanceRendering(text: string): GovernanceRendering | null;
export function verifySnsRendering(input: {
  payloadTextRendering: string;
  payload: Bytes;
  resolved: ResolvedForm;
  targetCanisterId: string;
  method: string;
  refs?: Record<string, unknown>;
  limits?: DecodeLimits;
}): { ok: boolean; checks: SnsCheck[]; expectedRendering: string | null; parsed: GovernanceRendering | null };
ExportDoes
snsValidatorRendering(input)Payload bytes in, { Ok: text } or { Err: text } out. Anything the generator cannot describe is Err, so governance rejects the proposal.
handleSnsValidatorCall, encodeValidatorResult, decodeValidatorResultThe canister-side wrapper and the codec of variant { Ok : text; Err : text }.
verifySnsRendering(input), parseGovernanceRendering(text)The reviewer's check of a stored payload_text_rendering: five checks, { ok, checks, expectedRendering, parsed }.
SNS_RENDERING_RULES"candid-profile-sns/v0", named in every rendering.

Experimental (spec §15.6 and §15.9): the consent text of an EVM call, from an EVM contract's resolved form and its calldata, and of an EIP-712 signing request, from a resolved form with messages and the request's JSON.

tsprofile/packages/consent/src/evm.d.mts
export type EvmResolvedForm = ResolvedForm & { chain: "evm" };
export function decodeCalldata(resolved: ResolvedForm, calldata: Uint8Array | string, limits?: AbiLimits): { method: string; selector: string; values: Record<string, AbiValue> };
export interface EvmConsentInput {
  resolved: ResolvedForm;
  calldata: Uint8Array | string;
  to: string;
  chainId?: number | bigint | string;
  refs?: Record<string, unknown>;
  tokens?: Record<string, { decimals: number | bigint | string; symbol?: string }>;
  form?: "generic" | "fields";
  utcOffsetMinutes?: number | null;
  rawArguments?: boolean;
  limits?: AbiLimits;
}
export function evmConsent(input: EvmConsentInput): {
  form: "generic" | "fields";
  message: string | { intent: string; fields: [string, string][] };
  method: string;
  selector: string;
  to: string;
  chainId: string | null;
  /** The decoded arguments by parameter name, as `decodeCalldata` gives them. */
  values: Record<string, AbiValue>;
  /** What the call grants (its token is the contract called, unless the amount's tag names a field), or null; a W_GRANT diagnostic says why one cannot be read. */
  grant: Grant | null;
  diagnostics: ConsentDiagnostic[];
  provenance: Provenance;
  model: Model;
};
export interface TypedData {
  types: Record<string, { name: string; type: string }[]>;
  primaryType: string;
  domain: { name?: string; version?: string; chainId?: number | bigint | string; verifyingContract?: string; salt?: string };
  message: Record<string, unknown>;
}
export interface Eip712ConsentInput {
  resolved: ResolvedForm;
  typedData: TypedData;
  chainId: number | bigint | string;
  refs?: Record<string, unknown>;
  tokens?: Record<string, { decimals: number | bigint | string; symbol?: string }>;
  form?: "generic" | "fields";
  utcOffsetMinutes?: number | null;
  rawArguments?: boolean;
  limits?: { maxBytes?: number; maxElements?: number };
}
export function eip712Consent(input: Eip712ConsentInput): {
  form: "generic" | "fields";
  message: string | { intent: string; fields: [string, string][] };
  primaryType: string;
  typeHash: string;
  digest: string;
  verifyingContract: string;
  chainId: string;
  /** The message's values by member name, as read from the typed data: integers as decimal strings, addresses in EIP-55 form. */
  values: Record<string, AbiValue>;
  /** What the signature grants (its token is the verifying contract, unless the amount's tag names a member), or null; a W_GRANT diagnostic says why one cannot be read. */
  grant: Grant | null;
  diagnostics: ConsentDiagnostic[];
  provenance: Provenance;
  model: Model;
};
ExportDoes
decodeCalldata(resolved, calldata, limits?)The function a selector names and its arguments, keyed by parameter name. Strict: EE_DECODE on anything but the canonical encoding; EE_RESOLVED_FORM for a form that is not an EVM one; EE_METHOD_UNKNOWN for an unknown selector.
evmConsent(input)The same model and two layouts as an ICRC-21 message, without its envelope, ending with the contract's address and chain. A call to a contract its profiles do not list is EE_DEPLOYMENT; a payable function is EE_UNSUPPORTED_TYPE.
values, grant (both functions, since 0.4.0)The decoded arguments or message values by name, and what the call or signature grants (the Grant above), or null. The grant's token is the contract called or the verifying contract, unless the amount's tag names a field that holds it (amount.token). Permit2's batch messages carry no grant, so theirs is null.
tokens (both functions)For an amount whose token names its token by a field: each token's decimals() and symbol(), keyed by its address, which the caller reads. An amount in a token not listed is EE_REF_UNRESOLVED; a listed one is shown with its token's address beside the symbol.
eip712Consent(input)The consent text of a signing request (eth_signTypedData_v4's JSON; types.EIP712Domain may be left out), matched to a message by its type hash, never its name. chainId is the wallet's active chain. Refused before anything is shown: a domain for another chain, without chainId or verifyingContract, with a salt, or whose name or version is not its profiles' (EE_DOMAIN); a contract its profiles do not list (EE_DEPLOYMENT); no message with the request's type hash (EE_TYPE_HASH); values that are not exactly the members, canonically written (EE_DECODE); a token not in tokens (EE_REF_UNRESOLVED). The text ends with the verifying contract and its chain, the EIP-712 digest, and "This is a signature, not a transaction: whoever holds it can submit it." For a pack's message, query: refs are calls to the verifying contract.

A request against the EIP-2612 pack, for a token deployed on a local Anvil chain (31337); the pack lists no deployments, and the token's decimals() and symbol() come in as refs:

js
import { createSchemaValidator, loadPacks, validateProfile } from "@meantjs/profile";
import { eip712Consent } from "@meantjs/consent/evm";

const validateSchema = await createSchemaValidator();
const erc2612 = (await loadPacks()).find((p) => p.doc.name === "erc2612");
const v = erc2612 && (await validateProfile(erc2612.doc, { validateSchema, resolved: true }));
if (!v?.resolved) throw new Error("the EIP-2612 pack did not resolve");

const r = eip712Consent({
  resolved: v.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: "0x5FbDB2315678afecb367f032d93F642f64180aa3" },
    message: {
      owner: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
      spender: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
      value: "1500000000000000000",
      nonce: 0,
      deadline: "115792089237316195423570985008687907853269984665640564039457584007913129639935",
    },
  },
  chainId: 31337,
  refs: { "query:decimals": 18, "query:symbol": "TKA" },
});
r.typeHash; // "0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9": EIP-2612's Permit
r.message; // "# Permit token spending\n\nAllow `0x3C44…93BC` to spend up to `1.5 TKA` from `0x7099…79C8`. Signature expires: `Never`. …"
r.grant; // { kind: "approve", spender: "0x3C44…93BC", token: "0x5FbD…0aa3", amount: { units: "1500000000000000000", unlimited: false }, ends: { never: true } }

The message then names the contract and its chain (0x5FbD…0aa3 on chain 31337), the EIP-712 digest and the signature note; the comments above are its real output, with the addresses shortened here. The grant never ends: a permit's deadline bounds only when the signature may be used, and the allowance it sets has no end (spec §5.3), so the pack's grant names no ends. The same request with chainId: 1 in its domain, to a wallet on chain 31337, is refused with EE_DOMAIN: "the domain is for chain 1, the wallet is on chain 31337".

Experimental (spec §15.7 and §15.9): ERC-7730 clear-signing files, of a contract's functions or of its EIP-712 messages, and the unsigned ERC-8176 attestation a reviewer signs for one. Nothing is signed or sent.

tsprofile/packages/consent/src/erc7730.d.mts
export const ERC7730_SCHEMA_URL: "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.1.0.schema.json";
export const ERC8176_SCHEMA_UID: "0xe023eef113c1670774801c34b377fdf612dd8a4d2fa92fe382e15bd91fafb5c2";
export const EAS_ADDRESS: "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587";
export interface Erc7730FieldGroup {
  path: string;
  fields: (Erc7730Field | Erc7730FieldGroup)[];
}
export interface Erc7730Options {
  context?: "contract" | "eip712";
  deployments?: Erc7730Deployment[];
  owner?: string;
  contractName?: string;
  info?: { url: string; deploymentDate?: string };
  token?: { name: string };
}
export function erc7730Descriptor(resolved: ResolvedForm, options?: Erc7730Options): { descriptor: Erc7730Descriptor; descriptorHash: string; notes: string[] };
export function descriptorHash(descriptor: object): string;
export interface Erc8176Input {
  descriptorHash: string;
  time: bigint | number | string;
  expirationTime: bigint | number | string;
  salt: string;
  refUID?: string;
}
export function erc8176Attestation(input: Erc8176Input): { typedData: Erc8176TypedData; digest: string };
ExportDoes
erc7730Descriptor(resolved, options?)The contract's ERC-7730 file and its ERC-8176 descriptorHash; deterministic. context: "contract" (the default) writes one entry per function a transaction calls, keyed by its named signature; context: "eip712" writes the messages' own file, keyed by encode_type, with context.eip712.domain the name and version its profiles bind. An array whose elements carry their own token is an Erc7730FieldGroup ("details.[]"). What a file cannot say is EE_EXPORT; no deployment is EE_DEPLOYMENT. What it cannot say but changes no value (a timestamp's words, an interpolation over an array) is left out and listed in notes; every file 0.2.0 writes is written the same.
descriptorHash(descriptor)0x and the Keccak-256 of the RFC 8785 form: the hash of a file someone edited.
erc8176Attestation(input)The unsigned offchain attestation (EAS offchain v2 typed data) and its EIP-712 digest, for a reviewer to sign with their own key.
ERC7730_SCHEMA_URL, ERC8176_SCHEMA_UID, EAS_ADDRESSThe exact-version schema every file names; ERC-8176's schema UID; the canonical EAS contract.

New in 0.4.0 (spec §5.3, "Confirmed terms", and §11 item 10, D-056): terms the person confirms for one task (the token, who may take it, the most and the latest end), and the check of a request's grant against them. A request whose grant fits terms the person confirmed before the agent read it may be signed or sent without a second confirmation; one that does not is refused with its reasons. Pure: the wallet passes its clock and zone in, nothing is rounded, and it runs in browsers. The guide is Confirm terms for a task.

tsprofile/packages/consent/src/terms.d.mts
import type { Grant } from "./index.mjs";

/** A term the agent wrote that cannot be read, or a step out of order: `message` is written for the agent. */
export declare class TermsError extends Error {
  constructor(code: string, message: string);
  readonly code: TermsCode;
}
export declare const TERMS_CODES: {
  /** A field is missing or empty, or both spender and to are given. */
  readonly MISSING: "E_TERMS_MISSING";
  /** A token the wallet does not know. */
  readonly TOKEN: "E_TERMS_TOKEN";
  /** An app the wallet does not know, or an address or account that is not one. */
  readonly PARTY: "E_TERMS_PARTY";
  /** An amount that cannot be read, or has more decimals than its token: never rounded. */
  readonly AMOUNT: "E_TERMS_AMOUNT";
  /** An end that cannot be read, or a date before today. */
  readonly END: "E_TERMS_END";
  /** termsGate: a step out of order. */
  readonly ORDER: "E_TERMS_ORDER";
};
export type TermsCode = (typeof TERMS_CODES)[keyof typeof TERMS_CODES];

/** What the terms functions read from the wallet. Pure: the wallet's clock and zone come in here. */
export interface Wallet {
  /** The tokens it knows, by symbol: an EVM token's address or a ledger's canister id, and its decimals. */
  tokens: Record<string, { address: string; decimals: number } | { canister: string; decimals: number }>;
  /** The apps and people it knows by name: an address, or an account in its ICRC-1 text. */
  apps?: Record<string, string>;
  /** Nanoseconds since the Unix epoch. */
  now: bigint;
  /** The user's zone (default 0, UTC): a date allows its whole day there. */
  utcOffsetMinutes?: number;
}

/** The terms as the agent writes them, a tool's arguments: `spender` or `to`, exactly one. */
export interface TermsFields {
  /** The token, by the symbol the wallet knows. */
  token: string;
  /** Who may spend the tokens: an app's name as the wallet knows it, or an address or account the user gave. */
  spender?: string;
  /** Who is paid directly: a name the wallet knows, or an address or account the user gave. */
  to?: string;
  /** The most, in tokens ("100", "0.5", "100 USDC"), or "unlimited". */
  max_amount: string;
  /** YYYY-MM-DD (the whole day counts), a length from today ("30 days", "2 weeks", "1 month", "1 year"), hours from now ("2 hours", an exact time), or "never". */
  expires_by: string;
}

/** What the user allows for one task, in the wallet's own values: `spender` or `to`, exactly one. */
export interface Terms {
  /** The token: an EIP-55 address, or a canister id in its canonical text. */
  token: string;
  symbol: string;
  decimals: number;
  spender?: string;
  to?: string;
  /** The app's name in the wallet's list, or null. */
  name: string | null;
  /** The most, in base units as a decimal string; `null` when unlimited. */
  amount: { units: string; unlimited: false } | { units: null; unlimited: true };
  /** `at`: the latest end, exact seconds since the Unix epoch as a decimal string; `day`: the local date whose whole day counts. */
  ends: { at: string; day?: string } | { never: true };
  utcOffsetMinutes: number;
  /** The fields as the agent wrote them, trimmed. */
  written: Record<string, string>;
}
/** Terms the user confirmed: `id` is the SHA-256 of their canonical JSON (RFC 8785), lowercase hex. */
export type ConfirmedTerms = Readonly<Terms & { id: string }>;
tsprofile/packages/consent/src/terms.d.mts
/** Reads the agent's terms. Throws TermsError when a term cannot be read; it never guesses a token, an app, decimals or a date. */
export function readTerms(fields: TermsFields | Record<string, unknown>, wallet: Wallet): Terms;
/** The terms in words, exactly, for the user to confirm on the wallet's own screen. */
export function termsText(terms: Terms | ConfirmedTerms): string;
/** The terms once the user has confirmed exactly `termsText(terms)`: frozen, with their `id`. */
export function confirmTerms(terms: Terms): ConfirmedTerms;
/** Whether a request's grant fits confirmed terms, and if not, why, in words. A null grant is not covered by terms. */
export function checkGrant(confirmed: ConfirmedTerms, grant: Grant | null, wallet: Wallet): { fits: boolean; reasons: string[] };

/** The order of one task: propose, confirm on the wallet's screen, read, check. Steps out of order throw TermsError E_TERMS_ORDER. */
export interface TermsGate {
  readonly state: "open" | "proposed" | "confirmed" | "read";
  readonly proposed: Readonly<Terms> | null;
  readonly confirmed: ConfirmedTerms | null;
  /** The agent's draft; replaces an earlier one and its confirmation. Returns the text to show the user. Refused once the request was read. */
  propose(terms: Terms): string;
  /** The user confirmed `shown`, which must be exactly the draft's text. */
  confirm(shown: string): ConfirmedTerms;
  /** The user said no: the draft is dropped. */
  reject(): void;
  /** Before the agent reads the request: refused until the user has confirmed terms. */
  read(): void;
  /**
   * The grant of the request read. `requestId` names the request the wallet will sign or send: eip712Consent's `digest`,
   * or for a call the wallet's own id of it. The terms cover one request, the first checked: another is refused, even
   * one that grants the same.
   */
  check(grant: Grant | null, wallet: Wallet, requestId: string): { fits: boolean; reasons: string[] };
}
export function termsGate(): TermsGate;
ExportDoes
readTerms(fields, wallet)The agent's terms in the wallet's values: a token by its symbol and an app by its name from the wallet's lists (case, spaces and punctuation set aside), never guessed; an address or account the person gave, checked; the most with parseAmount, never rounded; the end as a date (its whole day in the person's zone), a length (days and weeks count days, months and years calendar months), hours from now (an exact time) or "never". "unlimited" and "never" only as those words. Throws TermsError.
termsText(terms)The terms in words, exactly, for the wallet's own screen: the full address, the amount without rounding, and "any amount of … (unlimited)" or "and this permission never ends" written out.
confirmTerms(terms)Called once the person confirmed exactly termsText(terms) on the wallet's own screen, never through a tool the agent can call: the terms deep-frozen, with id. checkGrant refuses terms whose id no longer matches.
checkGrant(confirmed, grant, wallet){ fits, reasons }. A grant fits when its token and its spender or recipient are the terms', its amount is at most the terms' most (unlimited only under unlimited terms; a grant of nothing under any), and it ends no later than the terms' end (one that never ends only under terms that never end; a transfer while the terms have not ended). A null grant never fits: it is not covered by terms and needs its confirmation.
termsGate()The order of one task: propose(terms) returns the text to show, confirm(shown) takes the text the person confirmed, read() comes before the agent reads the request, check(grant, wallet, requestId). Terms cannot change once the request is read, and they cover one request, named by requestId (eip712Consent's digest, or the wallet's own id of a call): another is refused even when it grants the same. Out of order is EE_TERMS_ORDER.
TermsError, TERMS_CODES, TermsCodeThe six codes and their messages, written for the agent (TermsError). A wallet, terms or grant not in the shape these functions write is a ConsentError EE_REQUEST instead.
Wallet, TermsFields, Terms, ConfirmedTerms, TermsGateThe types above.

The permit of the /evm example, checked against two sets of terms. The token and Bob are the local Anvil addresses above, and now is the wallet's clock:

js
import { checkGrant, confirmTerms, readTerms, termsGate, termsText } from "@meantjs/consent/terms";

const wallet = {
  tokens: { TKA: { address: "0x5FbDB2315678afecb367f032d93F642f64180aa3", decimals: 18 } },
  apps: { Bob: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" },
  now: 1_793_620_800_000_000_000n, // 2026-11-02T12:00:00Z, in nanoseconds
};
const month = readTerms({ token: "TKA", spender: "Bob", max_amount: "2", expires_by: "30 days" }, wallet);
termsText(month); // "Bob (0x3C44…93BC) may spend at most 2 TKA, until the end of 2 December 2026 (UTC)."
checkGrant(confirmTerms(month), r.grant, wallet);
// { fits: false, reasons: ["the permission never ends, the terms say until the end of 2 December 2026 (UTC)"] }

const gate = termsGate();
const shown = gate.propose(readTerms({ token: "TKA", spender: "Bob", max_amount: "2", expires_by: "never" }, wallet));
// "Bob (0x3C44…93BC) may spend at most 2 TKA, and this permission never ends."
gate.confirm(shown); // the person confirmed exactly `shown` on the wallet's own screen
gate.read(); // in a wallet, the agent reads the request only after this
gate.check(r.grant, wallet, r.digest); // { fits: true, reasons: [] }

The comments are the real output, with the address shortened here. The first terms end; the permit never does, so it does not fit them. A second request checked through the same gate, under another requestId, is refused with EE_TERMS_ORDER: "these terms cover one request, the one already checked; another request is a new task, with new terms".

@meantjs/tools#

Agent tools from a Candid resolved form: MCP tool definitions in human units, a preview the person confirms before every send, and the retry and reconcile rules. One tool per query method, one prepare_<method> per update method, and commit. An EVM resolved form is refused: EVM tools are a later phase.

tsprofile/packages/tools/src/index.d.mts
export const COMMIT_TOOL: "commit";
export function prepareToolName(method: string): string;
export function caseToolName(method: string, label: string, read: boolean): string;
export function resolvedMethods(resolved: ResolvedForm | ResolvedForm[]): [string, unknown][];
export function buildToolPlans(resolved: ResolvedForm | ResolvedForm[]): { plans: Map<string, ToolPlan>; skipped: { method: string; reason: string }[] };
export function toolDefinitions(resolved: ResolvedForm | ResolvedForm[], options?: { canisters?: Canister[] }): ToolDefinition[];
export function readToolInput(
  plan: ToolPlan,
  input: unknown,
  context?: { refs?: Record<string, unknown>; now?: bigint; names?: Record<string, NameEntry[]> },
): { value: unknown; identity: [string, unknown][] } | { issues: InputIssue[] };
export function toCandid(node: unknown, value: unknown): unknown;
export function displayValue(node: unknown, value: unknown, refs?: Record<string, unknown>, names?: Record<string, NameEntry[]>): unknown;
export function nameEntries(resolved: unknown, set: string, reply: unknown): NameEntry[] | undefined;
export function classifyReply(plan: ToolPlan, reply: unknown): { cls: string; path: string[]; node: unknown; value: unknown };
export type CallResult =
  | { status: "replied"; reply: unknown }
  | { status: "rejected"; reject_code: number; message: string }
  | { status: "unknown"; message: string };
export type Outcome =
  | "declined"
  | "success"
  | "already_done"
  | "retry_same"
  | "retry_new"
  | "fix_input"
  | "fail"
  | "not_executed"
  | "unknown"
  | "reconcile";
export interface ToolLayerConfig {
  resolved: ResolvedForm | ResolvedForm[];
  canisters: Canister[];
  encode(method: string, value: unknown, args: unknown[]): Uint8Array | Promise<Uint8Array>;
  call(req: { canister: string; method: string; mode: string; arg: Uint8Array; value: unknown; args: unknown[] }): Promise<CallResult>;
  now(o: { canister: string }): Promise<bigint>;
  confirm?(preview: Preview): Promise<boolean | { approved: boolean }>;
  reconcile?(o: { canister: string; method: string; value: unknown; stamp: bigint; preview: Preview }): Promise<ReconcileFinding>;
}
export interface ToolLayer {
  listTools(): ToolDefinition[];
  skipped: { method: string; reason: string }[];
  callTool(name: string, input: unknown): Promise<ToolResult>;
}
export function createToolLayer(config: ToolLayerConfig): ToolLayer;
ExportDoes
createToolLayer(config)The layer an agent host serves: listTools() and callTool(name, input). confirm is awaited before every send and is required when the form has an update method; the model never confirms. now is the named canister's clock.
toolDefinitions(resolved, options?)MCP tool definitions, sorted by name.
buildToolPlans, resolvedMethods, prepareToolName, caseToolName, COMMIT_TOOLWhich tools a form gives and what each one fills; prepare_<method>; a tool per case of a variant argument (D-053); "commit".
readToolInput(plan, input, context?)Reads a tool call's inputs in human units: { value, identity } or { issues } (input issues).
classifyReply(plan, reply)The outcome class of a reply, by the method's outcome tag.
toCandid, displayValue, nameEntriesConverted values to candid-core shapes; values as a preview shows them; the entries of a names set in a reply.

@meantjs/reconcile#

Consumer rule 1 without a transport: after an unknown outcome, before any new attempt, is the original in the ledger's ICRC-3 history? The caller reads the blocks (certified, archives followed) and passes them in.

tsprofile/packages/reconcile/src/index.d.mts
export const DEDUP_WINDOW_NS: bigint;
export const U64_MAX: bigint;
export const METHODS: readonly ["icrc1_transfer", "icrc2_approve", "icrc2_transfer_from"];
export function expectedTransaction(
  method: string,
  arg: Record<string, unknown>,
  caller: string,
  options?: { minting?: Account | null },
): ExpectedTransaction | undefined;
export function blockMatches(block: Icrc3Value, expected: ExpectedTransaction | undefined): boolean;
export interface MatchOptions {
  logLength: bigint;
  floor?: bigint;
  inFlight: boolean;
  stampExpired?: boolean;
  ledgerTime?: bigint;
  ledgerTimeAtLeast?: bigint;
  windowNs?: bigint;
  notOriginal?: bigint[];
}
export type MatchResult =
  | { status: "found"; block: bigint }
  | { status: "absent"; closedBy: "answered" | "too_old" | "window_passed"; clock?: ClockReading }
  | { status: "not_yet"; openUntil?: bigint; reason?: string; clock?: ClockReading }
  | { status: "unreadable"; reason: string; lookalike?: { block: bigint; op: string; capped: boolean } };
export function matchBlocks(blocks: { id: bigint; block: Icrc3Value }[], expected: ExpectedTransaction | undefined, options: MatchOptions): MatchResult;
ExportDoes
expectedTransaction(method, arg, caller, options?)The ICRC-3 transaction a call leaves, or undefined for a method this module does not map, a call without a stamp, or an unreadable caller. options.minting is the ledger's icrc1_minting_account.
matchBlocks(blocks, expected, options)Judges the blocks read since the first send: found, absent, not_yet or unreadable; never absent while a send may still land or a block in the range could not be read.
blockMatches(block, expected)Whether one block carries exactly this transaction.
DEDUP_WINDOW_NSICRC-1 TX_WINDOW plus PERMITTED_DRIFT of the DFINITY ledger: 24 h 2 min, 86,520,000,000,000 ns. For a caller that has checked its ledger uses it.
U64_MAX, METHODSWhere the DFINITY ledger's u64 build caps an approval; the three methods mapped to a block.