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:
// 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#
npx candid-profile resolve tokens.did --verbose > /dev/nullcandid-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#
{
"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": []
}| Field | What it holds |
|---|---|
format, format_version | Always "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). |
name | Lowercase letters, digits, _, . and -, starting with a letter or digit, at most 64 characters. Diagnostics and the resolved form name the profile by it. |
origin | The 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. |
language | The language of its titles, templates and help text. Optional; "en" when absent. |
description | Optional free text for people reading the file. |
binds | For a canister profile: interface_id (required), and optionally contract_id and canister_id. Nothing else is allowed in it. |
annotations | At 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#
- 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.
- at
- type:Tokens.e8s
- tag
describe
- args
- label: Amount
{ "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.
- 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
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#
- 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
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#
- 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" } } }
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#
{
"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#
npx candid-profile resolve tokens.did --profile tokens.profile.json > tokens.resolved.jsoncandid-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:
cd profile
npm ci
node scripts/validate.mjs examples/tokens.profile.json --did examples/tokens.didPASS 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:
npx candid-profile resolve tokens.did --profile tokens.profile.json --verbose > /dev/nullcandid-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:
| Change | Diagnostic | Message |
|---|---|---|
send(0).memmo for send(0).memo | EE_ADDR_UNRESOLVED | no field "memmo" in record |
{recipient} for {to} in the template | EE_TEMPLATE_PATH | {recipient}: no field "recipient" in record |
An outcome key Err.Insufficient.balance | EE_OUTCOME_NOT_CASE | outcome "Err.Insufficient.balance" must select a variant case |
amount at type:Tokens | EE_TAG_NOT_APPLICABLE | amount needs a numeric type, found record |
An effect of transfer on the query balance | EE_EFFECT_MODE | query method cannot have effect kind "transfer" |
A top-level field "version" | EE_SCHEMA | must NOT have additional properties {"additionalProperty":"version"} |
Another interface_id in binds | WW_REBOUND | profile 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#
- Annotate a .did file: the same tags as doc comments in
tokens.did. - Validate and resolve profiles: merge this profile with packs and other layers from code.
- Serve a profile from a canister: put it in the Wasm, where any client can read it.
- Security model: what a profile can vouch for, and what it cannot.