Profiles

Write a profile

Describe a canister's methods once: units, intent, effects, retries and outcomes, bound to its interface.

This guide writes the profile for a small token canister, one annotation at a time, and ends at the example profile the repository ships and checks. Start from its interface:

candidprofile/examples/tokens.did
// Example canister interface for a canister (author) profile that uses type: addresses.
type Tokens = record { e8s : nat64 };
type SendArgs = record {
  to : principal;
  amount : Tokens;
  memo : opt blob;
  created_at : opt nat64;
};
type SendError = variant {
  Insufficient : record { balance : Tokens };
  Duplicate : record { block : nat };
  Other : text;
};
service : {
  balance : (principal) -> (Tokens) query;
  send : (SendArgs) -> (variant { Ok : nat; Err : SendError });
}

The interface says e8s is a nat64. It does not say that 1 TOK is 100,000,000 of them, that send moves value and cannot be undone, that created_at is what makes a retry safe, or that Err.Duplicate means the transfer already happened. The profile says those things. You need @meantjs/profile installed (npm install --save-exact @meantjs/profile@0.5.0) and the two files in one folder: tokens.did and the tokens.profile.json you are about to write.

1. Get the interface id#

bash
npx candid-profile resolve tokens.did --verbose > /dev/null
text
candid-profile: tokens.did  candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c  [2 methods]
  0 error, 0 warning, 2 info
  info    I_UNTAGGED_NUMERIC         send(0).created_at  nat64 argument without amount/timestamp/duration/describe
  info    I_UNTAGGED_NUMERIC         send(0).amount.e8s  nat64 argument without amount/timestamp/duration/describe

With no profile, candid-profile resolve compiles the interface and prints its interface_id on the first line of the summary. That id covers the callable interface and nothing else: renaming a type or editing a comment leaves it where it is, and changing a method moves it. The profile binds to it. npx candid-core-cli gen tokens.did, from @candid-core/cli (a dependency of @meantjs/profile), prints the same id on its interface: line, and writes a TypeScript module and an envelope beside the file.

The two infos are a to-do list: both numbers an agent could be asked to fill have no meaning attached yet (II_UNTAGGED_NUMERIC). By the end of this guide neither is left.

2. Write the document fields#

jsontokens.profile.json
{
  "format": "candid-profile",
  "format_version": 0,
  "kind": "canister",
  "name": "tokens-example",
  "origin": "author",
  "language": "en",
  "description": "Example author profile for examples/tokens.did. …",
  "binds": {
    "interface_id": "candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c"
  },
  "annotations": []
}
FieldWhat it holds
format, format_versionAlways "candid-profile" and 0.
kind"canister" for a profile of one interface. "pack" is a standard pack that matches by subtyping; "contract" is an EVM contract (experimental).
nameLowercase letters, digits, _, . and -, starting with a letter or digit, at most 64 characters. Diagnostics and the resolved form name the profile by it.
originThe layer it merges in: author if you wrote the canister, third_party for a profile someone else chose to trust, inferred for one a model proposed, which is active only once a person approves it.
languageThe language of its titles, templates and help text. Optional; "en" when absent.
descriptionOptional free text for people reading the file.
bindsFor a canister profile: interface_id (required), and optionally contract_id and canister_id. Nothing else is allowed in it.
annotationsAt least one: each an at (an address), a tag and its args, and an optional note.

Any other top-level field is EE_SCHEMA. The profile has an identity of its own, the profile_id: candid-profile:v0:sha256: over the RFC 8785 canonical JSON of the document. You do not write it in; the tools compute it, and any edit changes it. Profiles, layers and binding explains the layers and what binding does.

3. Tag the values#

Profile annotationinterface_idcandid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c
at
type:Tokens.e8s
tag
amount
args
  • decimals: 8
  • symbol: TOK

In the file this is { "at": "type:Tokens.e8s", "tag": "amount", "args": { "decimals": 8, "symbol": "TOK" } }. A type: address starts from a declaration, so this one annotation reaches every position the Tokens.e8s edge leads to: the amount send takes, the balance balance returns, and the balance inside Err.Insufficient. A type: address is allowed in a canister profile only; a pack has to name each position by its method.

Profile annotation
at
type:Tokens.e8s
tag
describe
args
  • label: Amount
jsonprofile/examples/tokens.profile.json
{ "at": "balance->(0).e8s", "tag": "describe", "args": { "label": "Balance" } }

Every Tokens is labelled "Amount", except the one balance returns. balance->(0).e8s is a method address (->(0) is the first result), and within one profile a method address beats a type: address at the same position, with no diagnostic.

Profile annotation
at
send(0).created_at
tag
timestamp
args
  • unit: ns
  • epoch: unix
Profile annotation
at
send(0).memo
tag
bytes
args
  • encoding: utf8
Profile annotation
at
send(0).memo
tag
default
args
  • value: null
  • mode: suggest

send(0) is the first argument. created_at counts nanoseconds since the Unix epoch. memo is opt blob, and the path passes through the opt: the tag applies to the blob, and the resolved form marks the position "optional": true. bytes with utf8 lets a person type the memo as text. default holds a Candid literal: "null" leaves the memo out unless someone fills it, and suggest keeps the field visible to an agent (auto would hide it and fill it). A null default on a position that is not optional is EE_DEFAULT_NULL.

Each value tag applies to certain Candid types only; amount on the record type:Tokens instead of its nat64 field is EE_TAG_NOT_APPLICABLE. Values and exact conversion covers what each tag accepts and shows, and Tags lists all 18.

4. Say what the call does#

Profile annotation
at
send
tag
intent
args
  • title: Send TOK
  • template: Send {amount.e8s} to {to}[ with memo {memo}]
Profile annotation
at
send
tag
effect
args
  • kind: transfer
  • irreversible: true

The intent is the line a person reads before the call is sent. Its title is at most 60 characters. Each {placeholder} is a path relative to the method's arguments, written without the leading (0) because send has one argument, and it is rendered with the formatter of the tag at that position: {amount.e8s} shows 1.5 TOK, not 150000000. A position with no formatting tag renders as Candid text, and a principal such as {to} as its textual form. A segment in [ … ] is dropped when a placeholder inside it is absent, so a call without a memo reads "Send 1.5 TOK to …". Write {{, }}, [[ and ]] for the literal characters. A template says what the call does with its arguments, never what the state will be afterwards ("your balance after this is…").

The effect is what the call does to value: read, write, transfer, approve, admin or destroy, and whether it is irreversible. A consumer asks the person to confirm every transfer, approve, admin and destroy. Without the tag, a query is a read and an update is a write with irreversible: true. The effect has to fit the method's mode: a query declared as anything but read is EE_EFFECT_MODE, and an update declared as read gets WW_EFFECT_MODE. Intent, effects and confirmation covers case intents and the read-only update.

5. Make retries safe#

Profile annotation
at
send
tag
idempotency
args
  • mode: replay_same_payload
  • stamp: created_at
jsonprofile/examples/tokens.profile.json
{ "at": "send", "tag": "outcome", "args": { "classes": { "Ok": "success", "Err.Duplicate": "already_done", "Err.Insufficient": "fail", "Err.Other": "fail" } } }

idempotency names the field the tool layer fills once per intent, here created_at. The tool layer stamps it from the canister's clock, encodes the arguments once and sends exactly those bytes on every retry of that intent, so the canister can recognise a repeat. The stamp field has to carry a timestamp tag, which step 3 gave it. Without an idempotency tag, a consumer never retries an update whose outcome is unknown.

outcome gives each result case a class, so the caller knows what to do next: success, already_done (an identical call already executed; Err.Duplicate carries the block it went into), retry_same, retry_new, fix_input or fail. Each key is a path from the result to a variant case. Here every case has a class; a case without one falls back to default when the tag sets it, and a consumer treats a case with neither as fail. Outcomes and retries explains the classes and the consumer's rules after an unknown outcome.

6. Put it together#

jsonprofile/examples/tokens.profile.json
{
  "format": "candid-profile",
  "format_version": 0,
  "kind": "canister",
  "name": "tokens-example",
  "origin": "author",
  "language": "en",
  "description": "Example author profile for examples/tokens.did. Shows type: addresses (one tag on Tokens.e8s reaches every Tokens position), a method-address override, and the idempotency stamp.",
  "binds": {
    "interface_id": "candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c"
  },
  "annotations": [
    { "at": "type:Tokens.e8s", "tag": "amount", "args": { "decimals": 8, "symbol": "TOK" } },
    { "at": "type:Tokens.e8s", "tag": "describe", "args": { "label": "Amount" } },
    { "at": "balance->(0).e8s", "tag": "describe", "args": { "label": "Balance" } },
    { "at": "send", "tag": "intent", "args": { "title": "Send TOK", "template": "Send {amount.e8s} to {to}[ with memo {memo}]" } },
    { "at": "send", "tag": "effect", "args": { "kind": "transfer", "irreversible": true } },
    { "at": "send", "tag": "idempotency", "args": { "mode": "replay_same_payload", "stamp": "created_at" } },
    { "at": "send", "tag": "outcome", "args": { "classes": { "Ok": "success", "Err.Duplicate": "already_done", "Err.Insufficient": "fail", "Err.Other": "fail" } } },
    { "at": "send(0).created_at", "tag": "timestamp", "args": { "unit": "ns", "epoch": "unix" } },
    { "at": "send(0).memo", "tag": "bytes", "args": { "encoding": "utf8" } },
    { "at": "send(0).memo", "tag": "default", "args": { "value": "null", "mode": "suggest" } }
  ]
}

This is profile/examples/tokens.profile.json as the repository ships it.

7. Validate it#

bash
npx candid-profile resolve tokens.did --profile tokens.profile.json > tokens.resolved.json
text
candid-profile: tokens.did  candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c  [2 methods]
  applied     tokens-example   author       candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c
  0 error, 0 warning, 0 info

The profile is applied: its interface_id is the one the .did compiles to. The exit status is 0 when no diagnostic is an error and 1 when one is; tokens.resolved.json holds the resolved form either way, as long as the .did compiles. The two II_UNTAGGED_NUMERIC infos from step 1 are gone. The command checks the document against the JSON Schema, resolves every address, checks that each tag fits its position's Candid type, and checks the templates, the stamp and the outcome keys.

In a checkout of the repository, the single-profile validator gives the same verdict:

bash
cd profile
npm ci
node scripts/validate.mjs examples/tokens.profile.json --did examples/tokens.did
text
PASS examples/tokens.profile.json  (0 error, 0 warning, 0 info)
     profile_id   candid-profile:v0:sha256:70abc8cbce701650b6adafeabbf636a10b9c80e620bfcb6cc2d8793ee027745c
     interface_id candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c  [2 methods, 10 annotations]

scripts/validate.mjs is a repository script, not part of the published package. It checks one profile at a time and never merges or matches packs; without --did a canister profile gets only the schema check and WW_NO_INTERFACE. The candid-profile command compares the two.

When validation fails#

Leave out the timestamp tag on send(0).created_at, a common slip when the stamp field is added last:

bash
npx candid-profile resolve tokens.did --profile tokens.profile.json --verbose > /dev/null
text
candid-profile: tokens.did  candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c  [2 methods]
  applied     tokens-example   author       candid-profile:v0:sha256:0f7685d0958814f829fca5dbca285931da35aa7fa5deaa95625ee338d64dd2d1
  1 error, 0 warning, 1 info
  error   E_STAMP_UNTAGGED           [tokens-example] send  stamp field "created_at" needs a timestamp annotation
  info    I_UNTAGGED_NUMERIC         send(0).created_at  nat64 argument without amount/timestamp/duration/describe

The exit status is 1. Each diagnostic line is its severity, its code, the profile it is about in brackets, the address it is at, and a message. The profile_id changed with the edit. These are the other mistakes worth knowing, each run against a copy of the example with one change:

ChangeDiagnosticMessage
send(0).memmo for send(0).memoEE_ADDR_UNRESOLVEDno field "memmo" in record
{recipient} for {to} in the templateEE_TEMPLATE_PATH{recipient}: no field "recipient" in record
An outcome key Err.Insufficient.balanceEE_OUTCOME_NOT_CASEoutcome "Err.Insufficient.balance" must select a variant case
amount at type:TokensEE_TAG_NOT_APPLICABLEamount needs a numeric type, found record
An effect of transfer on the query balanceEE_EFFECT_MODEquery method cannot have effect kind "transfer"
A top-level field "version"EE_SCHEMAmust NOT have additional properties {"additionalProperty":"version"}
Another interface_id in bindsWW_REBOUNDprofile is bound to … but the given interface is …; checking whether every address still resolves

WW_REBOUND is what you see after the interface changes. The resolver checks every address and tag against the new interface: when all of them still hold, the profile is rebound and applies with that warning; when any fails, it is stale (EE_STALE), and the resolved form leaves out its value-conversion tags (amount, timestamp, duration, account, subaccount, bytes), so no amount is converted with decimals that may no longer be true. Update binds.interface_id once you have checked the profile against the new interface. Every code is on Profile diagnostics.

Next#