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#

bash
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.json
js
import { 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:

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

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

FieldIts tagsIn the tool
toaccountAn input: account text, checksum verified.
amountamountAn input in human units: "1.5 TKA".
memobytes, default with mode: suggestAn optional input.
from_subaccountsubaccountAn optional input.
feedefault with mode: autoNot an input. The layer fills it with null, so the ledger takes its own fee.
created_at_timethe idempotency stamp, ui.hiddenNot 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#

js
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's encodeArgs of args, the whole argument tuple, as here. The arguments arrive in candid-core's domain shapes: T | null for an opt, bigint for nat, Uint8Array for a blob, principals as text.
  • call({ canister, method, mode, arg, value, args }) sends arg exactly, 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 }. A call that 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#

js
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"
The model sees1.5 TKA
prepare_icrc1_transfer({
  to: "k2t6j-…-teztq-6ae",
  amount: "1.5 TKA",
  memo: "lunch"
})
The call sends150000000
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:

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

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

text
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 with status: "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 from aaaaa-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 CreatedInFuture the new stamp is capped by the ledger_time the error reports.

Why the stamp makes this safe, and what the spec requires, is on outcomes and retries.

Outcomes#

OutcomeWhenThe intent
declinedconfirm said noSettled if it had no attempt, else unchanged. After an unknown outcome the result also carries earlier_outcome: "unknown".
success, already_donethe class of the replyDone. A later commit answers already_done without sending.
retry_samethe class of the replyUnchanged; commit again. The third one stops it.
retry_newthe class of the replyA new preview with a new stamp, under next; the old one is retired.
fix_input, failthe class of the replySettled. details carries the hint, formatted: expected_fee as 0.0001 TKA.
not_executedreject code 1, 2 or 3Unchanged.
unknownno answer, another reject code, or a call that throwsUnknown: commit again sends the same bytes; preparing it again returns the same preview.
reconcileafter an unknown outcome, any answer but success, already_done or retry_sameHeld 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:

text
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#

jsprofile/packages/tools/test/names.test.mjs
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 principal is a text input, its checksum checked (EE_PRINCIPAL).
  • An untagged integer with a describe is a text input of decimal digits, within the Candid type's bounds.
  • text and bool positions, checked against their text tag.
  • A record, variant or vec that no value tag reads is one JSON input, each leaf read by the rules above; at most 256 elements in a vec and 4096 values in one input.
  • A method with more than one argument takes them as arg0, arg1 and 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 intent or effect on 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 call does, 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.