Skip to content

Reconsider canister-calls: one skill for calling canisters, JS/TS first #406

Description

@marc0olo

How to call a canister is explained in pieces across about 20 skills, and the pieces have drifted apart. Agents are the consumers of these skills, and an agent only sees the copy in the skill it loaded. This issue proposes one owner, canister-calls, with JS/TS as the main entry, the CLI and inter-canister calls as references, and every other skill linking to it instead of carrying its own copy.

Drift found while working on #405

  • host: internet-identity:150 sets host: window.location.origin (fixed in docs(internet-identity): cover local II minting with auth 10 #404). icp-cli, custom-domains and binding-generation.md say never to. encrypted-maps:105 requires a host parameter without saying what to pass.
  • rootKey: encrypted-maps:107 says it is "undefined on mainnet". dfx-migration.md:26 says the ic_env cookie always carries it. certified-variables:380 typed it as ArrayBuffer (fixed in fix(certified-variables): update to certificate-verification 4 and fix broken examples #407). static-site/references/legacy-asset-canister.md:104 still uses shouldFetchRootKey, which three skills ban.
  • Agent construction: there are three patterns and no rule for choosing between them:
    • bindgen createActor({ agentOptions });
    • HttpAgent.create + Actor.createActor;
    • a pre-built agent handed to a library client (encrypted-maps, IcrcLedgerCanister, AssetManager).
  • Sibling IDs: multi-canister:243 uses the dfx idiom import "canister:user_service", and :66 names the removed ic_cdk::call. icp-cli pitfall 22 says to read PUBLIC_CANISTER_ID:<name>.
  • Inter-canister calls: bounded_wait and unbounded_wait are used across skills with no rule for when to use which.
  • Frontend ledger calls: icrc-ledger:50 says transfers should not originate from the frontend, while wallet-integration is built on frontend transfers through a signer.

Proposed shape

canister-calls/
├── SKILL.md                # JS/TS (browser + Node): HttpAgent vs createActor vs library clients,
│                           #   host/rootKey decision table, ic_env cookie, identity wiring,
│                           #   Candid↔TS types, Node scripts/tests against a local network
└── references/
    ├── cli.md              # icp canister call: Candid text args, the '()' rule, --query/--identity/-o
    └── inter-canister.md   # Rust Call::bounded_wait vs unbounded_wait (with a rule), Motoko actor refs,
                            #   (with cycles)/(with timeout), IDs via PUBLIC_CANISTER_ID

Candid↔TS types. Checked against bindgen 0.4.1. The mapping follows one principle: a native JavaScript form where one exists, and a tagged object only where none does.

Candid TypeScript
opt T (argument, return value, vec element, variant payload) T | null
record field opt T f?: T, undefined when empty
record field opt opt T f?: T | null, three states: omitted, null, a value (after dfinity/icp-js-bindgen#198)
nested opt opt T elsewhere, or a third level Some<…> | None
named variant without payload string enum under its name
inline variant without payload today a Variant_<tags> enum, or a borrowed enum of a named type with the same tags; a string-literal union if dfinity/icp-js-bindgen#172 is adopted
variant with payload { __kind__: "Ok", Ok: … } ("Ok" in r works)
blob Uint8Array
principal imported from @icp-sdk/core/principal

Check "empty" with == null or ??: it is right in every position, so an agent does not need to know which one it is in. The icp-cli skill's "always use T | null" is wrong for record fields and is fixed in place first (#409).

These fill the type gaps left over from #156.

Canister IDs stay in icp-cli. It owns ID resolution (PUBLIC_CANISTER_ID:<name> injection, canister-env-vars.md, --id-only, mappings) and the tooling side (bindgen setup, candid:, dev server, dfx migration mapping). canister-calls links to it for IDs.

Draft host/rootKey decision table (from #405; resolution rules per @icp-sdk/core 6.1.0 determineHost, Node row verified locally)
Code runs in Calls host rootKey
Browser page served by the local network that local network leave unset → resolves to http://localhost:<port> IC_ROOT_KEY from the ic_env cookie
Browser page on <canister-id>.icp.net (the default mainnet URL) or a custom domain mainnet leave unset → resolves to https://icp-api.io, because neither is a known gateway domain; this is what makes custom domains work, since a custom domain serves only the HTTP gateway, not /api/v2 IC_ROOT_KEY from the ic_env cookie
Browser page on a legacy <canister-id>.icp0.io or ic0.app URL mainnet leave unset → resolves to https://icp0.io / https://ic0.app IC_ROOT_KEY from the ic_env cookie
Browser page mainnet, but the page is served by a local network (e.g. mainnet ledger calls from a local dev server) "https://icp-api.io" omit — defaults to the mainnet key; do not pass the page's local IC_ROOT_KEY
Node script or test a local network api_url from icp network status --json root_key from the same output, hex-decoded to bytes
Node script or test mainnet leave unset → resolves to https://icp-api.io omit — defaults to the mainnet key

When writing the table into the skill, note that the ic_env cookie's root key is only as trustworthy as the page. On a verifying hostname the gateway verifies it along with the page: certified-assets certifies set-cookie: ic_env, and the legacy asset canister keeps it in the HTML asset's certified headers. A page from raw can carry a forged key (developer-docs#408 review).

Skills to update

The action for each skill is to move its generic calling content out, keep what is specific to its domain, and link with "Load the canister-calls skill …".

Skill Move to canister-calls / link Keep Also fix
icp-cli pitfall 13; binding-generation.md actor, host and opt sections bindgen setup, ID resolution, dev-server.md, dfx migration mapping description no longer covers actor setup
internet-identity generic agent/actor setup (:101-165, :447-489) AuthClient, agentOptions for II minting lands after #404
wallet-integration plain-agent reads SignerAgent flows; the cross-network host row becomes a table row reconcile with icrc-ledger pitfall 7
custom-domains the host section (:179-193) pitfall 8, reduced to one line —
encrypted-maps agent setup (:93-112, :152) client usage host parameter, "undefined on mainnet"
vetkeys agent imports/setup (:23, :203) vetKD flows —
certified-variables — certificate validation, root-key sources done in #407 (Uint8Array, LookupResult statuses, --query)
static-site the CLI '()' rule (:206) ic_env cookie contents (it sets the cookie) legacy-asset-canister.md shouldFetchRootKey
multi-canister call mechanics, bounded vs unbounded architecture, factory pattern, payload limits import "canister:…", ic_cdk::call, Rust init-arg IDs, description
canister-security — fetchRootKey and reentrancy pitfalls link for call mechanics
icrc-ledger, ckbtc, evm-rpc — domain flows and well-known IDs say why unbounded_wait is used; link for mechanics
cloud-engine-canisters, deploy-to-cloud-engine — engine rules (0 cycles, bounded_wait, --proxy) link
agent-web-identity — --identity web-link flow (pitfall 9) link for CLI arguments
canhelp, service-discoverability — Candid discovery (they own it) link both ways
writing-motoko (upstream-tracked) — body synced from upstream link only in the owned "Additional References" section
caffeine-app — platform-specific useActor / env.json state the Caffeine-only scope explicitly

These skills contain CLI call examples only and need an optional link at most: https-outcalls, cycles-management, sns-launch, stable-memory.

Recommendations for the open questions

  • Proposal: canister-calls skill — Candid discovery + consolidated canister workflows #88: close it as superseded by this issue. Keep ckbtc, icrc-ledger and evm-rpc as standalone skills, since Proposal: canister-calls skill — Candid discovery + consolidated canister workflows #88's trigger evals showed the dedicated skills win, and have them link to canister-calls for mechanics.
  • Other agents (Python, Rust ic-agent, Go): leave them out for now. No skill covers them and there is no demand yet; add a reference when there is.
  • Candid discovery: leave it with canhelp (mainnet interfaces, with scripts) and service-discoverability. canister-calls links to them instead of adding candid-discovery.md.
  • Inter-canister calls: canister-calls owns the mechanics. multi-canister keeps the architecture, and its description drops "bounded vs unbounded wait" so the two skills don't compete on triggers.
  • Descriptions and triggers: the canister-calls description names what it owns (HttpAgent, createActor, agentOptions, host, rootKey, ic_env, icp canister call, Candid arguments, inter-canister calls). It also says what belongs elsewhere: deploying and IDs go to icp-cli, token flows to the domain skills, interface lookup to canhelp. Run the repo-wide trigger evals for icp-cli, multi-canister and canister-calls together.

Writing it for agents (lesson from #405)

  • A SKILL.md summary stops the model from opening references. With a host table in icp-cli's SKILL.md, eval case 28 stopped reading binding-generation.md and scored 0/6 against main's 6/6. The rule, its reason and a worked example must sit together in SKILL.md; references should carry only material an agent can safely skip.
  • The pointer's wording steers the reading. A main bisect in fix(icp-cli): drop false claim that createActor ignores { agent } #405 showed that the pitfall text alone, not the reference, moved case 28. A pointer naming rootKey/identity produced explanations about rootKey. Naming host correctly sent the model back to the host guidance. So the pointer should name the topics the reader will need.
  • Every cross-reference says when to load the other skill, not just that it exists.
  • Migrate the related evals too: icp-cli cases 17, 28 and 30, the internet-identity agent-setup case, and the adversarial host/rootKey cases.

Refs #88, #156, #404, #405

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions