Skip to main content

JavaScript agent

Intermediate
Agents
Tutorial

Overview

An agent is an API client used to interact with ICP. The JavaScript agent (agent-js) is used to interact with the public ICP API endpoints and canisters deployed on ICP. agent-js provides call, query, and readState methods, along with other utilities, to an actor.

Calls made through the agent are intended to be structured through an actor and configured with a canister's interface that can be generated from a Candid interface. Raw calls are supported, but not recommended per best practices.

Installation

Prerequisites

To get started with agent-js, it is recommended that your development environment include:

  • IC SDK for canister creation and management.
  • Node JS (12, 14, or 16).
  • A canister you want to experiment with.   - Suggestions:     - dfx new default project.     - An example from dfinity/examples.

To install the JavaScript agent, run the following command:

npm i --save @dfinity/agent

Authentication

agent-js supports authentication through auth-client, which uses the Internet Identity service.

First, install the auth-client package:

npm i --save @dfinity/auth-client

Create an authentication client that will open an identity.ic0.app window, save your delegation to indexedDb, and provide you with an identity:

const authClient = await AuthClient.create();
The authClient can log in with

authClient.login({
  // 7 days in nanoseconds
  maxTimeToLive: BigInt(7 * 24 * 60 * 60 * 1000 * 1000 * 1000),
  onSuccess: async () => {
    handleAuthenticated(authClient);
  },
});

Then, use that identity to make authenticated calls using the @dfinity/agent actor:

const identity = await authClient.getIdentity();
const actor = Actor.createActor(idlFactory, {
  agent: new HttpAgent({
    identity,
  }),
  canisterId,
});

Storage and key management

By default, agent-js uses a default IndexedDb storage interface and ECDSA keys.

You can use any storage structure that implements the AuthClientStorage interface.

export type StoredKey = string | CryptoKeyPair;
export interface AuthClientStorage {
  get(key: string): Promise<StoredKey | null>;

  set(key: string, value: StoredKey): Promise<void>;

  remove(key: string): Promise<void>;
}

If you decide to use a custom storage implementation that only supports string data types, it is recommended to use the keyType option to use an Ed25519 key instead of the default ECDSA key:

const authClient = await AuthClient.create({
  storage: new LocalStorage(),
  keyType: 'Ed25519',
});

Session management

There are two options supported by the AuthClient for secure session management:

  1. The maxTimeToLive option built into the Internet Identity delegation determines in nanoseconds how long the DelegationIdentity returned will be valid for.

  2. The Idle Manager, which monitors keyboard, mouse, and touchscreen activity. The Idle Manager will automatically log you out if the browser is not interacted with for a period of time.

    - By default, this period is 10 minutes. After 10 minutes, the DelegationIdentity will be removed from from localStorage and window.location.reload() is called.

    - Alternatively, you can pass an onIdle option that will replace the default window.location.reload() behavior, or register callbacks with idleManager.registerCallback(), which will also replace the default callback.

View the full set of options for the IdleManager.

Initializing an actor

Agents are most commonly used to create an actor using the Actor.createActor constructor:

Actor.createActor(interfaceFactory: InterfaceFactory, configuration: ActorConfig): ActorSubclass<T>

The interfaceFactory function returns a runtime interface that the actor uses to structure calls made to a canister. This function is generated by running the dfx generate command in your project or using the didc tool to generate interfaces for your project. Alternatively, this function can be written manually, though it is not recommended.

Actors in the context of the JavaScript agent are used to:

  • Poll for updates.

  • Build the Candid-encoded body of the agent's request.

  • Parse the response.

  • Provide type type safety for the canister's interface.

Making a call to the HttpAgent by itself without the use of an actor is an advanced use case that is unnecessary for most applications.

To set up an actor, you can import a createActor utility from your canister's Candid declarations and canisterId alias, which by default points to process.env<canister-id>_CANISTER_ID.

You can pass the canister ID environment variable logic to your application in a number of ways, such as:

  • Edit it into the start of your package.json scripts (NFT_CANISTER_ID=... node....).

  • Install dotenv and configure it to read from a hidden .env file.

The following example uses local development, where you can read the canister ID from the local canister_ids.json file, which can be found at .dfx/local/canister_ids.json:

// src/node/index.js
import fetch from "isomorphic-fetch";
import { HttpAgent } from "@dfinity/agent";
import { createRequire } from "node:module";
import { canisterId, createActor } from "../declarations/agent_js_example/index.js";
import { identity } from "./identity.js";

// Require syntax is needed for JSON file imports
const require = createRequire(import.meta.url);
const localCanisterIds = require("../../.dfx/local/canister_ids.json");

// Use `process.env` if available, or fall back to local
const effectiveCanisterId =
  canisterId?.toString() ?? localCanisterIds.agent_js_example.local;

const agent = new HttpAgent({
  identity: await identity,
  host: "http://127.0.0.1:4943",
  fetch,
});

const actor = createActor(effectiveCanisterId, {
  agent,
});

HTTP headers

An actor can be initialized to include the boundary node HTTP headers using the Actor.createActor constructor:

Actor.createActorWithHttpDetails(interfaceFactory: InterfaceFactory, configuration: ActorConfig): ActorSubclass<ActorMethodMappedWithHttpDetails<T>>

Inspecting an actor's agent

To get the agent of an actor, use the Actor.agentOf method:

const defaultAgent = Actor.agentOf(defaultActor);

This method can be used to replace or invalidate an identity used by an actor's agent. For example, the following method can be used to replace the current identity of an actor's agent with a newly authenticated identity from Internet Identity:

defaultAgent.replaceIdentity(await authClient.getIdentity());

Making calls

agent-js supports the following methods for making calls to ICP:

  • call: Used to make update calls.

  • query: Used to make query calls.

  • readState: Used to read state information from the ICP replica.

Raw calls can be made using the agent, but it is recommended to use the agent's generated calls instead.

Update calls

Update calls on ICP make a change to the canister's state. To make an update call with agent-js, use the call method:

call(canisterId: string | Principal, fields: CallOptions): Promise<SubmitResponse>

This method is defined in packages/agent/src/agent/api.ts:178.

Parameters for this method are:

  • canisterId: The canister's principal ID in the format string.

  • fields: The call's options. Possible options are defined in CallOptions.

The call method returns Promise<[SubmitResponse](https://agent-js.icp.xyz/agent/interfaces/SubmitResponse.html)>.

Query calls

Query calls on ICP do not change a canister's state and are only used to return information. To make an update call with agent-js, use the query method:

query(canisterId: string | Principal, options: QueryFields, identity?: Identity | Promise<Identity>): Promise<ApiQueryResponse>

This method is defined in packages/agent/src/agent/api.ts:200

Parameters for this method are:

  • canisterId: The canister's principal ID in the format string. Note that sending a query to the management canister is not supported, as it has no meaning from an agent.

  • options: Options used to create and send the query. Possible options are defined in QueryFields.

  • identity: Optional; identifies the sender principal to use when sending the query.

The query method returns Promise<ApiQueryResponse>. This is the response from the replica, and will be rejected if the communication failed. If the query itself failed but there were no protocol errors, the response will be of type QueryResponseRejected.

Read state information

To read state information from the ICP replica, use the readState method. This method makes a query call with a list of paths to return, and receives a certificate in response. If there are communication errors, the call will be rejected, and the certificate may contain less information than what was requested.

readState(effectiveCanisterId: string | Principal, options: ReadStateOptions, identity?: Identity, request?: any): Promise<ReadStateResponse>

This method is defined in packages/agent/src/agent/api.ts:170

Parameters for this method are:

  • effectiveCanisterId: A canister's principal ID related to the call in the format string.

  • options: The call's options. Possible options are defined in ReadStateOptions.

  • identity: Optional; identifies the sender principal to use when sending the query. If not specified, it uses the instance identity.

  • request: Optional; the request to send in case it has already been created.

This method returns Promise<ReadStateResponse>.

Browser

When you are building apps that run in the browser, you can use the fetch API as described below. Most apps will be able to determine whether they need to talk to https://icp0.io or a local replica, depending on their URL.

Using fetch

agent-js uses the web browser's fetch API to make calls to ICP. When the agent isn't used in the browser, a fetch implementation can be passed to the agent's constructor. This can be useful if you want to include custom fetch implementation data, such as adding an authentication header to your request.

It is recommended to use the isomorphic-fetch package to provide a consistent fetch API across Node.js and the browser. You will need to provide a host option to the agent's constructor, since the agent will not be able to determine the host from the global context.

An example can be found below:

import fetch from 'isomorphic-fetch';
import { HttpAgent } from '@dfinity/agent';

const host = process.env.DFX_NETWORK === 'local' ? 'http://127.0.0.1:4943' : 'https://icp-api.io';

const agent = new HttpAgent({ fetch, host });

You can also pass fetchOptions to the agent's constructor, which is then passed to the fetch implementation. This is useful if you want to pass additional options to the fetch implementation. An example specifying a custom header can be found below:

import fetch from 'isomorphic-fetch';
import { HttpAgent } from '@dfinity/agent';

const host = process.env.DFX_NETWORK === 'local' ? 'http://127.0.0.1:4943' : 'https://ic0.app';

/**
 * @type {RequestInit}
 */
const fetchOptions = {
  headers: {
    'X-Custom-Header': 'value',
  },
};

const agent = new HttpAgent({ fetch, host, fetchOptions });

Performance

When developing an app that runs in the browser, it is important to take application performance into consideration. Updates to ICP may feel slow to your users, at around 2-4 seconds. When building your application, take that latency into consideration and consider following these best practices:

  • Avoid blocking UI interactions while you wait for the result of your update. Instead, allow users to continue to make other updates and interactions, and inform your users of success asynchronously.

  • Try to avoid making inter-canister calls. If the backend needs to talk to other canisters, the latency can add up quickly.

  • Use Promise.all to make multiple calls in a batch, instead of making them one by one.

  • If you need to fetch assets or data, you can make direct fetch calls to the raw.icp0.io endpoint of canisters.

Bundlers

It is recommended to use a bundler to assemble your code for convenience and simplified troubleshooting. Below is an example that provides a standard Webpack configuration, but you may also use other frameworks such as Rollup, Vite, Parcel, or others.

For this pattern, it is recommended to run a script to generate the .env.development and .env.production environment variable files for your project's canister IDs. This is a fairly standard approach for bundlers, and can be easily supported using dotenv. An example of this script can be found below:

// setupEnv.js
const fs = require("fs");
const path = require("path");

function initCanisterEnv() {
  let localCanisters, prodCanisters;
  try {
    localCanisters = require(path.resolve(
      ".dfx",
      "local",
      "canister_ids.json"
    ));
  } catch (error) {
    console.log("No local canister_ids.json found");
  }
  try {
    prodCanisters = require(path.resolve("canister_ids.json"));
  } catch (error) {
    console.log("No production canister_ids.json found");
  }

  const network =
    process.env.DFX_NETWORK ||
    (process.env.NODE_ENV === "production" ? "ic" : "local");

  const canisterConfig = network === "local" ? localCanisters : prodCanisters;

  const localMap = localCanisters
    ? Object.entries(localCanisters).reduce((prev, current) => {
        const [canisterName, canisterDetails] = current;
        prev[canisterName.toUpperCase() + "_CANISTER_ID"] =
          canisterDetails[network];
        return prev;
      }, {})
    : undefined;
  const prodMap = prodCanisters
    ? Object.entries(prodCanisters).reduce((prev, current) => {
        const [canisterName, canisterDetails] = current;
        prev[canisterName.toUpperCase() + "_CANISTER_ID"] =
          canisterDetails[network];
        return prev;
      }, {})
    : undefined;
  return [localMap, prodMap];
}
const [localCanisters, prodCanisters] = initCanisterEnv();

if (localCanisters) {
  const localTemplate = Object.entries(localCanisters).reduce((start, next) => {
    const [key, val] = next;
    if (!start) return `${key}=${val}`;
    return `${start ?? ""}
          ${key}=${val}`;
  }, ``);
  fs.writeFileSync(".env.development", localTemplate);
}
if (prodCanisters) {
  const prodTemplate = Object.entries(prodCanisters).reduce((start, next) => {
    const [key, val] = next;
    if (!start) return `${key}=${val}`;
    return `${start ?? ""}
        ${key}=${val}`;
  }, ``);
  fs.writeFileSync(".env", localTemplate);
}

Then, you can add "prestart" and "prebuild" commands of dfx generate; node setupEnv.js. Follow the documentation for your preferred bundler on how to work with environment variables.

Example

Below is an example that creates an agent, fetches the root key of the replica, then creates an actor automatically through the Candid interface of the canister.

This example uses fetchRootKey. It is not recommended that dapps deployed on the mainnet call this function from agent-js, since using fetchRootKey on the mainnet poses severe security concerns for the dapp that's making the call. It is recommended to put it behind a condition so that it only runs locally.

This API call will fetch a root key for verification of update calls from a single replica, so it’s possible for that replica to respond with a malicious key. A verified mainnet root key is already embedded into agent-js, so this only needs to be called on your local replica, which will have a different key from mainnet that agent-js does not know ahead of time.

import { Actor, HttpAgent } from "@dfinity/agent";

// Imports and re-exports candid interface
import { idlFactory } from "./backend_canister.did.js";
export { idlFactory } from "./backend_canister.did.js";

/* CANISTER_ID is replaced by webpack based on node environment
 * Note: canister environment variable will be standardized as
 * process.env.CANISTER_ID_<CANISTER_NAME_UPPERCASE>
 * beginning in dfx 0.15.0
 */
export const canisterId =
  process.env.CANISTER_ID_BACKEND_CANISTER ||
  process.env.BACKEND_CANISTER_CANISTER_ID;

export const createActor = (canisterId, options = {}) => {
  const agent = options.agent || new HttpAgent({ ...options.agentOptions });

  if (options.agent && options.agentOptions) {
    console.warn(
      "Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent."
    );
  }

  // Fetch root key for certificate validation during development
  if (process.env.DFX_NETWORK !== "ic") {
    agent.fetchRootKey().catch((err) => {
      console.warn(
        "Unable to fetch root key. Check to ensure that your local replica is running"
      );
      console.error(err);
    });
  }

  // Creates an actor using the candid interface and the HttpAgent
  return Actor.createActor(idlFactory, {
    agent,
    canisterId,
    ...options.actorOptions,
  });
};

export const backend_canister = createActor(canisterId);

Resources