Profiles
Give an agent tools
Tool definitions in human units, a preview the person confirms, and retries that cannot pay twice.
@meantjs/tools turns a resolved form into tools an agent host can offer a model: one
per query method, one prepare_<method> per update method, and one
commit. The model writes amounts and accounts the way a person does. The tool layer
converts them exactly, fills what the profile says it fills, shows the person a preview, and sends
nothing until the person confirms. It brings no transport and no keys: you inject the encoder, the
call and the clock. Why a transfer waits for the person is on
intent, effects and confirmation, what the inputs mean on
values and exact conversion, and every export is on the
JavaScript API reference.
Build the tools#
npm install --save-exact @meantjs/tools@0.5.0 @meantjs/profile@0.5.0 @candid-core/schema@0.5.0
npx candid-profile resolve ledger.did --packs > ledger.resolved.jsonimport { readFileSync } from "node:fs";
import { buildToolPlans, createToolLayer, toolDefinitions } from "@meantjs/tools";
const resolved = JSON.parse(readFileSync("ledger.resolved.json", "utf8"));
const TKA = "bkyz2-fmaaa-aaaaa-qaaaq-cai"; // a local ledger
const canisters = [{ id: TKA, label: "TKA", refs: { "query:icrc1_decimals": 8, "query:icrc1_symbol": "TKA" } }];
const tools = toolDefinitions(resolved, { canisters }); // MCP tool definitions, sorted by name
const { skipped } = buildToolPlans(resolved); // the methods that got no tool, each with its reason
On the resolved form of a ledger with the ICRC-1 and ICRC-2 packs that gives 22 tools, among them
icrc1_balance_of, icrc1_fee, prepare_icrc1_transfer,
prepare_icrc2_approve, prepare_icrc2_transfer_from and
commit. Five methods are skipped, and skipped says why rather than
throwing:
[
{ "method": "get_blocks", "reason": "start: no rule for a nat position without a value tag or a describe" },
{ "method": "get_transactions", "reason": "start: no rule for a nat position without a value tag or a describe" },
{ "method": "icrc103_get_allowances", "reason": "take: no rule for a nat position without a value tag or a describe" },
{ "method": "icrc21_canister_call_consent_message", "reason": "an update method needs the intent tag" },
{ "method": "icrc3_get_blocks", "reason": "(0)[].start: no rule for a nat position without a value tag or a describe" }
]This is what the model sees of prepare_icrc1_transfer, abridged:
{
"name": "prepare_icrc1_transfer",
"description": "Prepare the call \"Send tokens\" (`icrc1_transfer`, effect: transfer, irreversible). Nothing is sent yet. Inputs are in human units and are checked exactly (nothing is rounded). The tool layer fills in: fee (left empty); created_at_time (the idempotency stamp, set once from the canister's clock and reused for every retry). Returns a preview_id and the exact arguments; commit sends it after the person confirms. Preparing the same call again returns the same preview_id.",
"inputSchema": {
"type": "object",
"properties": {
"canister_id": { "type": "string", "enum": ["bkyz2-fmaaa-aaaaa-qaaaq-cai"], "description": "Canister id of the canister to call: bkyz2-fmaaa-aaaaa-qaaaq-cai (TKA)." },
"to": { "type": "string", "description": "Recipient. ICRC-1 account in text form: a principal for its default subaccount, or \"principal-checksum.subaccounthex\" for another subaccount. …" },
"memo": { "type": "string", "description": "Memo. Text, stored as its UTF-8 bytes. … Optional: leave it out for none." },
"from_subaccount": { "type": "string", "description": "From subaccount. …", "pattern": "…" },
"amount": {
"type": "string",
"description": "Amount. Amount in TKA: a decimal number with at most 8 digits after the point, optionally followed by the symbol TKA. Digits on both sides of a point, no thousands separators or exponents. Never rounded: more digits after the point is an error, even zeros. For example \"1\", \"1.25\", \"1.25 TKA\".",
"pattern": "^\\s*[0-9]+(?:\\.[0-9]{1,8})?(?:\\s+[Tt][Kk][Aa])?\\s*$"
}
},
"required": ["canister_id", "to", "amount"],
"additionalProperties": false
},
"annotations": { "destructiveHint": false, "openWorldHint": false, "readOnlyHint": true, "title": "Send tokens" }
}What the model fills, and what the layer fills#
The record TransferArg has six fields. The model is asked for four of them, beside
canister_id, and only to and amount are required:
| Field | Its tags | In the tool |
|---|---|---|
to | account | An input: account text, checksum verified. |
amount | amount | An input in human units: "1.5 TKA". |
memo | bytes, default with mode: suggest | An optional input. |
from_subaccount | subaccount | An optional input. |
fee | default with mode: auto | Not an input. The layer fills it with null, so the ledger takes its own fee. |
created_at_time | the idempotency stamp, ui.hidden | Not an input. The layer sets it once per intent from the ledger's clock. |
A value-tagged position is a text input whose description is Formatter.describe() of
@meantjs/format, with the canister's decimals and symbol filled in (when several canisters
disagree, the words give the grammar without the numbers), and whose pattern is the
formatter's own. The stamp, auto defaults and ui.hidden positions never
reach the model (spec §5.3). A prepare tool sends nothing, so it is annotated
readOnlyHint: true; the commit tool is the one that sends.
Run the tool layer#
import { serviceMethods } from "@candid-core/schema";
import { schemaFromContract } from "@candid-core/schema/contract";
import { encodeArgs } from "@candid-core/schema/codec";
// candid-core's encoder for the ledger: npx @candid-core/cli@0.4.0 gen ledger.did -o generated
const loaded = schemaFromContract(JSON.parse(readFileSync("generated/ledger.envelope.json", "utf8")));
if (!loaded.ok || loaded.actor === undefined) throw new Error("the ledger envelope does not load");
const methods = serviceMethods(loaded.actor);
/** @type {import("@meantjs/tools").ToolLayerConfig["encode"]} */
const encode = (method, value, args) => {
const signature = methods.get(method);
if (signature === undefined) throw new Error(`no method ${method}`);
const encoded = encodeArgs(signature.args, args);
if (!encoded.ok) throw new Error(`${method}: the arguments do not encode`);
return encoded.bytes;
};
/** A scripted local ledger, as the package's tests use; in your host, send `arg` with your agent. */
/** @type {import("@meantjs/tools").ToolLayerConfig["call"]} */
const call = async ({ mode }) =>
mode === "query" ? { status: "replied", reply: 150_000_000n } : { status: "replied", reply: { tag: "Ok", value: 1042n } };
/** @type {import("@meantjs/tools").ToolLayerConfig["now"]} */
const now = async () => 1_791_547_201_000_000_000n; // the ledger's clock, ns
/** @type {NonNullable<import("@meantjs/tools").ToolLayerConfig["confirm"]>} */
const confirm = async (preview) => window.confirm(`${preview.title}\n${JSON.stringify(preview.arguments, null, 2)}`);
const layer = createToolLayer({ resolved, canisters, encode, call, now, confirm });The host supplies four functions; createToolLayer checks them when it is built:
-
encode(method, value, args)turns the arguments into the bytes that are sent. Use candid-core'sencodeArgsofargs, the whole argument tuple, as here. The arguments arrive in candid-core's domain shapes:T | nullfor anopt,bigintfornat,Uint8Arrayfor ablob, principals as text. -
call({ canister, method, mode, arg, value, args })sendsargexactly, as a"query"or an"update"call, and answers{ status: "replied", reply }(the first result value decoded, a variant as{ tag, value }),{ status: "rejected", reject_code, message }or{ status: "unknown", message }. Acallthat throws counts as an unknown outcome. -
now({ canister })is that canister's clock in nanoseconds, not the host's: canisters on different subnets keep different times, and the stamp must be one the ledger accepts. -
confirm(preview)is awaited before every send, a re-send included. There is no auto-approve default: a form with an update method needs it. An optional fifth,reconcile, is below.
Prepare, confirm, commit#
const prepared = await layer.callTool("prepare_icrc1_transfer", {
canister_id: TKA,
to: "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
amount: "1.5 TKA",
memo: "lunch",
});
const preview = prepared.structuredContent.preview; // nothing has been sent
if (preview === undefined) throw new Error("not prepared");
const result = await layer.callTool("commit", { preview_id: preview.preview_id }); // awaits confirm(preview)
result.structuredContent.outcome; // "success"prepare_icrc1_transfer({
to: "k2t6j-…-teztq-6ae",
amount: "1.5 TKA",
memo: "lunch"
})icrc1_transfer(record {
amount = 150_000_000;
memo = opt blob "lunch";
fee = null;
created_at_time =
opt 1_791_547_201_000_000_000;
…
})
The prepare tool answers with a short text for the model and structuredContent for the
host. The text starts Prepared. Nothing has been sent; commit it with its preview_id once the
person has seen it. The preview is what confirm receives:
{
"preview_id": "p1",
"method": "icrc1_transfer",
"canister": "bkyz2-fmaaa-aaaaa-qaaaq-cai",
"title": "Send tokens",
"effect": { "kind": "transfer", "irreversible": true, "grant": { "to": "to", "amount": "amount" } },
"arguments": {
"to": {
"text": "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae",
"raw": { "owner": "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae", "subaccount": null }
},
"fee": null,
"memo": { "text": "lunch", "raw": "6c756e6368" },
"from_subaccount": null,
"created_at_time": { "text": "2026-10-09T12:00:01Z", "raw": "1791547201000000000" },
"amount": { "text": "1.5 TKA", "raw": "150000000" }
},
"arg_hex": "4449444c066c06fbca0101c6fcb60204ba89e5c20402a2de94eb060282f3f3910c05d8a38ca80d7d6c02b3b0dac30368ad86ca8305026e036d7b6e7d6e780100011db56bf994b37ae8e79f5ce000be1727a6060ae4eef24736b7cc999c3c02000001056c756e63680001004af3f1afdadc1880a3c347"
}
Every argument is shown formatted and raw, and arg_hex is the exact bytes that will be
sent. Show the person the title, the effect and the formatted values, with the raw ones beside them
for a transfer. The effect is the profile's, so its grant names the
positions of who is paid and how much, not their values; a wallet that checks a request against terms
the person confirmed once for a task reads the values with @meantjs/consent
(Confirm terms for a task). A host could render it as:
Given the same bytes, @meantjs/consent writes the ICRC-21 message of
consent messages from the same intent template. On commit, the layer awaits confirm, sends
arg once, and classifies the reply by the method's outcome tag. Here the
ledger answered Ok: the text is Success: the call was executed. and
structuredContent is
{ "outcome": "success", "case": "Ok", "details": "1042", … }. A decline sends nothing and
answers declined.
Input the formatter refuses is never sent. "1.123456789 TKA" answers
Nothing was sent. with one issue per input, each with its place and the format library's
code:
{ "param": "amount", "path": "$args[0].amount", "code": "E_EXCESS_PRECISION",
"message": "\"1.123456789 TKA\" has 9 fractional digits but this token has 8; amounts are never rounded, so give at most 8" }Retries that cannot pay twice#
An intent is the canister, the method and the converted arguments without the stamp. The layer stamps
each intent once and keeps the encoded bytes. From the scripted ledger, with its first answer a
timeout and its second Err.Duplicate:
prepare Prepared. Nothing has been sent; commit it with its preview_id once the person has seen it.
commit The outcome is unknown: it may have executed. Commit the same preview_id again; do not prepare a new call.
prepare The outcome is unknown: it may have executed. Commit the same preview_id again; do not prepare a new call.
{"status":"repeated","outcome":"unknown","preview":{"preview_id":"p1",…}}
commit Already done: an identical call had already been executed. Treat it as done.
{"outcome":"already_done","case":"Err.Duplicate","details":{"duplicate_of":"1042"},…}-
One intent, one stamp. Preparing it again, here with
amount: "1.50", which converts to the same base units, returns the same preview withstatus: "repeated". Only a settled intent is stamped again, with a later stamp, and the person confirms it again. -
Unknown, then the same bytes. After a timeout the second commit resends the exact
bytes, stamp included. Inside the ledger's deduplication window it executes once or answers
Duplicate, which is already_done, never a second payment. -
Rejects. A reject with code 1, 2 or 3 means the call did not execute
(
not_executed); any other code (4, 5, 6 or one not listed), and any reject fromaaaaa-aa, is an unknown outcome. A third reject 4 or 5 of one intent needs a reconciliation, so a call that traps every time is not committed forever. - A budget. The third retry_same of an intent stops it (spec §5.3, consumer rule 3). Nothing sleeps in the layer: retry_same is handed back, and the caller or the person commits again.
-
A new stamp is a new confirmation. retry_new prepares a new
preview with a new stamp and retires the old one; the person confirms it again. After
CreatedInFuturethe new stamp is capped by theledger_timethe error reports.
Why the stamp makes this safe, and what the spec requires, is on outcomes and retries.
Outcomes#
| Outcome | When | The intent |
|---|---|---|
declined | confirm said no | Settled if it had no attempt, else unchanged. After an unknown outcome the result also carries earlier_outcome: "unknown". |
| success, already_done | the class of the reply | Done. A later commit answers already_done without sending. |
| retry_same | the class of the reply | Unchanged; commit again. The third one stops it. |
| retry_new | the class of the reply | A new preview with a new stamp, under next; the old one is retired. |
| fix_input, fail | the class of the reply | Settled. details carries the hint, formatted: expected_fee as 0.0001 TKA. |
not_executed | reject code 1, 2 or 3 | Unchanged. |
unknown | no answer, another reject code, or a call that throws | Unknown: commit again sends the same bytes; preparing it again returns the same preview. |
| reconcile | after an unknown outcome, any answer but success, already_done or retry_same | Held until the original is found (then done) or reported absent (then settled). |
The classes come from the method's outcome tag, through the result's variant cases; a
case with no class and no default is fail. The model reads the text, which says
what to do next (Not executed: correct the input and prepare the call again.); the host
reads structuredContent. Every outcome but success,
already_done and declined is marked isError: true, and so is
refused input.
Reconcile after an unknown outcome#
When a timeout is followed by TooOld, the deduplication window has passed and the first
attempt may have executed. Resending cannot settle that, and a new stamp could pay twice. Without a
reconcile hook the commit answers reconcile and holds the intent:
An earlier attempt may have executed. Tell the person what happened; do not send or prepare this call again until they decide.
{"outcome":"reconcile","preview_id":"p1","finding":{"status":"unreadable","reason":"this tool layer has no reconcile hook","after":"Err.TooOld"}}
With a hook, reconcile({ canister, method, value, stamp, preview }) looks for the original
in the ledger's history and answers { status: "found", block }, "absent",
"not_yet" or "unreadable". Found is already_done; absent
settles the intent, so a new preparation gets a new stamp and a new confirmation. For ICRC-1 and
ICRC-2 ledgers that check is @meantjs/reconcile:
settle an unknown outcome shows the hook.
An update without an idempotency tag gets a prepare tool too, and its description says it
has no stamp. After an unknown outcome it is never sent again: the intent is held at
reconcile and the hook is not called, since there is no stamp to match.
Names#
assert.deepEqual(read("1"), { value: 1, identity: [["integer", "1"]] });
assert.deepEqual(read(" savings "), { value: 4, identity: [["integer", "4"]] }); // the same intent as "4"
assert.equal(read("Main").issues[0].code, "E_NAME_AMBIGUOUS");
assert.match(read("Main").issues[0].message, /2 entries of account are named "Main" \(1, 2\): ask the person which one/);
assert.equal(read("Vault").issues[0].code, "E_NAME_NOT_FOUND");
assert.equal(read("Savings", {}).issues[0].code, "E_NAMES_UNREAD");
Where a profile marks a position named, the model may write a name in place of the value.
When the call is prepared, the layer calls the query that lists the names set and resolves the input
by the rule every consumer shares: a valid value is that value and is not looked up; otherwise exactly
one matching entry gives its value. More than one is EE_NAME_AMBIGUOUS and the model asks
the person, none is EE_NAME_NOT_FOUND, and a list that could not be read is
EE_NAMES_UNREAD. A name and the value it stands for are one intent, and the preview shows the
value with the name beside it. The rule is on names.
Beyond the packs#
The layer has rules for interfaces the standard packs do not cover. Each method without a rule is skipped with its reason.
- An untagged
principalis a text input, its checksum checked (EE_PRINCIPAL). - An untagged integer with a
describeis a text input of decimal digits, within the Candid type's bounds. textandboolpositions, checked against theirtexttag.- A record, variant or
vecthat no value tag reads is one JSON input, each leaf read by the rules above; at most 256 elements in avecand 4096 values in one input. - A method with more than one argument takes them as
arg0,arg1and so on. - A read-only update (
effect.replicated: true) is a read tool, sent once as an update call so its answer is certified; no preview, no confirmation. - A method whose argument is a variant with an
intentoreffecton its cases gets one tool per case,prepare_<method>_<Case>.
Not here#
-
Agent tools for EVM contracts. fact EVM support is experimental and its phase 1 has
no agent tools, outcome classes or idempotency (spec §15). Given a resolved form with
"chain": "evm", the layer throws. -
The payer. The layer does not choose the identity that sends a call; your
calldoes, and holds the keys. The trust boundaries are on the security model page. - fact From the package README: the demo's agent tools are not switched to this package yet, and the eval's tool arm (arm C) keeps its own frozen code.