HttpAgent
Manages the IC HTTP agent for all canister communication, handling network detection and initialization
ClientManager is the central orchestrator that unifies three essential concerns of IC development into a single, coherent system:
HttpAgent
Manages the IC HTTP agent for all canister communication, handling network detection and initialization
Identity
Holds the active identity and swaps it into the agent when it changes.
Internet Identity sign-in itself lives in AuthenticationManager
(@ic-reactor/react)
QueryClient
Integrates TanStack Query for automatic caching, deduplication, and background data updates
Without ClientManager, connecting to the IC typically involves:
HttpAgent manuallyClientManager solves this by providing a unified interface where all these concerns are managed together. When the identity changes, the agent is updated, the affected queries invalidate, and your reactors seamlessly use the new identity.
// One ClientManager, shared by all your reactorsconst clientManager = new ClientManager({ queryClient })
// Create multiple reactors - they all share the same agent and identityconst backend = new Reactor<BackendService>({ clientManager, idlFactory, name: "backend", canisterId,})const ledger = new Reactor<LedgerService>({ clientManager, idlFactory: ledgerIdl, name: "ledger", canisterId: ledgerId,})
// Sign in once - all reactors automatically use the new identityconst authentication = new AuthenticationManager({ clientManager })await authentication.login()import { ClientManager } from "@ic-reactor/core"import { ClientManager } from "@ic-reactor/core"import { QueryClient } from "@tanstack/query-core"
const queryClient = new QueryClient()
const clientManager = new ClientManager({ queryClient,})| Option | Type | Default | Description |
|---|---|---|---|
queryClient |
QueryClient |
Required | TanStack Query client instance |
agentOptions |
HttpAgentOptions |
{} |
Options forwarded to the HttpAgent (host, identity, rootKey, retryTimes, verifyQuerySignatures, …) |
allowEnvConfig |
boolean |
host-dependent | Whether to trust the ic_env cookie: its root key, its Internet Identity provider, and the canister IDs a reactor resolves by name. Defaults to true only for hosts that are unambiguously a local replica (loopback, localhost and its subdomains, dev-container tunnel domains); every other host must opt in. |
allowEnvRootKey |
boolean |
— | Deprecated. The former name of allowEnvConfig. Still honoured, and still scoped to what it always granted: the root key alone, not the identity provider or the canister ID. |
Network detection needs no flag — see Network Configuration.
| Property | Type | Description |
|---|---|---|
agent |
HttpAgent |
The IC HTTP agent (getter) |
queryClient |
QueryClient |
TanStack Query client |
agentState |
AgentState |
Current agent initialization state |
agentHost |
URL | undefined |
The host URL of the agent |
network |
"ic" | "local" | "remote" |
Current network type |
isLocal |
boolean |
Whether connected to local replica |
trustsEnvConfig |
boolean |
Whether the ic_env cookie is trusted for this host (getter) |
interface AgentState { isInitialized: boolean isInitializing: boolean isLocalhost: boolean network: string | undefined // "ic" | "local" | "remote" error: Error | undefined}Initialize the agent:
await clientManager.initialize()This method:
Session restoration is not part of this call — it belongs to AuthenticationManager.authenticate() (or the useAuth hook, which calls it for you).
Initialize the HttpAgent. initialize() delegates to this method, so the two are interchangeable:
await clientManager.initializeAgent()Swap the agent’s identity and invalidate the queries of every registered canister:
clientManager.updateAgent(newIdentity)In-flight queries for those canisters are cancelled first, so results signed by the previous identity cannot land in the cache. Queries that do not belong to a registered canister — the rest of your app’s REST/GraphQL data — are left untouched.
Get the current user’s Principal. This forwards agent.getPrincipal(), so it returns a promise:
const principal = await clientManager.getUserPrincipal()Subscribe to agent state changes:
const unsubscribe = clientManager.subscribeAgentState((state) => { console.log("Agent state:", state) // { isInitialized, isInitializing, isLocalhost, network, error }})
// Laterunsubscribe()Subscribe to identity changes:
const unsubscribe = clientManager.subscribe((identity) => { console.log("New identity:", identity.getPrincipal().toText())})Register a canister ID for tracking:
clientManager.registerCanisterId(canisterId, "backend")This is called automatically when creating a Reactor.
Get all registered canister IDs:
const canisterIds = clientManager.connectedCanisterIds()import { ClientManager, Reactor } from "@ic-reactor/core"import { QueryClient } from "@tanstack/query-core"import { idlFactory, type _SERVICE } from "../declarations/backend"
// Create QueryClient with defaultsexport const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, // 1 minute gcTime: 5 * 60_000, // 5 minutes retry: 3, }, },})
// Create ClientManagerexport const clientManager = new ClientManager({ queryClient,})
// Create Reactorexport const backend = new Reactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,})// Browser: the host is inferred from window.location.origin when the page is// served from localhost/127.0.0.1 or from an IC boundary domainconst clientManager = new ClientManager({ queryClient })
// Explicit local host — non-browser environments, or a replica on a custom portconst clientManager = new ClientManager({ queryClient, agentOptions: { host: "http://127.0.0.1:8080" },})
// Custom agent optionsconst clientManager = new ClientManager({ queryClient, agentOptions: { host: "https://icp-api.io", verifyQuerySignatures: true, },})When your app is served by the @icp-sdk toolchain (e.g., icp-cli) or by @ic-reactor/vite-plugin, the dev server injects an ic_env cookie carrying the local canister IDs and root key. ClientManager reads that cookie automatically in the browser. Host detection needs no option to enable; everything the cookie carries is gated on one decision, exposed as the trustsEnvConfig getter and controlled by allowEnvConfig:
window.location.origin when the page is served from a local or IC boundary host::1), localhost and its subdomains, and the dev-container domains that tunnel a local replica. Cookies are not origin-isolated and the root key is what certificate verification is checked against, so any other host — including a custom domain — must opt in with allowEnvConfig: true.PUBLIC_CANISTER_ID:<name>), used when a reactor is constructed without an explicit canisterId, and accepted on exactly the same hosts. On any other host the reactor throws instead, naming the option to set. A substituted canister ID is not something certificate verification can catch — the attacker names a real canister, and its responses verify against the real mainnet root key — so it fails closed rather than silently talking to someone else’s canister.// In React with useEffectuseEffect(() => { const unsubIdentity = clientManager.subscribe((identity) => { console.log("New identity:", identity.getPrincipal().toText()) })
const unsubAgent = clientManager.subscribeAgentState((state) => { if (state.isInitialized) { console.log("Agent ready, network:", state.network) } })
return () => { unsubIdentity() unsubAgent() }}, [])Sign-in lives on AuthenticationManager from @ic-reactor/react; ClientManager only receives the resulting identity.
import { AuthenticationManager } from "@ic-reactor/react"
const authentication = new AuthenticationManager({ clientManager })
// Initialize the agent on app startawait clientManager.initialize()
// Restore a previous session, if anyawait authentication.authenticate()
if (authentication.authState.isAuthenticated) { console.log("Welcome back!", (await clientManager.getUserPrincipal()).toText())}
// Login handlerasync function handleLogin() { await authentication.login({ onSuccess: () => { console.log("Logged in!") // Queries for connected canisters are automatically invalidated }, onError: (error) => { console.error("Login failed:", error) }, })}
// Logout handlerasync function handleLogout() { await authentication.logout() // Queries for connected canisters are automatically invalidated}const clientManager = new ClientManager({ queryClient })
// Check current networkconsole.log(clientManager.isLocal) // true if localconsole.log(clientManager.network) // "local", "remote", or "ic"console.log(clientManager.agentHost?.toString()) // The host URLWhen the identity changes, updateAgent() sweeps the cached queries of every canister registered with this ClientManager:
getQueryData or fetchQuery under the new identity// This happens automatically on identity change, for each connected canister:queryClient.cancelQueries({ queryKey: [canisterId] })queryClient.removeQueries({ queryKey: [canisterId], type: "inactive" })queryClient.invalidateQueries({ queryKey: [canisterId] })Because the scope is the canister ID — the first segment of every reactor query key — non-reactor queries in a shared QueryClient are not refetched or removed on sign-in or sign-out.
The @icp-sdk/auth package is an optional peer dependency. ClientManager never imports it. AuthenticationManager loads it through a dynamic import("@icp-sdk/auth/client"), so bundlers put it in a separate chunk and drop it entirely from apps that never construct one.
# Only install if you need authenticationnpm install @icp-sdk/authWhile AuthenticationManager covers Internet Identity, ClientManager itself is provider-agnostic and works with any identity provider. You can integrate alternative authentication methods by updating the agent’s identity.
When using external authentication (wallets, SIWE, NFID, etc.), update the agent after authenticating:
// Authenticate with your external providerconst identity = await externalAuthProvider.getIdentity()
// Update ClientManager - all reactors automatically use the new identityclientManager.updateAgent(identity)This pattern works with any provider that produces a valid Identity object.
We’re exploring first-class support for popular authentication standards:
| Provider | Description | Status |
|---|---|---|
| SIWE | Sign-In With Ethereum — use MetaMask, WalletConnect, etc. | 🔮 Planned |
| SIWS | Sign-In With Solana — use Phantom, Solflare, etc. | 🔮 Planned |
Here’s how SIWE integration works with ic-siwe-js:
// 1. Wrap your app with SiweIdentityProviderimport { SiweIdentityProvider } from "ic-siwe-js/react"
function App() { return ( <SiweIdentityProvider canisterId={siweProviderCanisterId}> <YourApp /> </SiweIdentityProvider> )}
// 2. Use the useSiwe hook to login and get identityimport { useSiwe } from "ic-siwe-js/react"
function LoginButton() { const { login, identity, isLoggingIn } = useSiwe()
// After login, update ClientManager with the SIWE identity useEffect(() => { if (identity) { clientManager.updateAgent(identity) } }, [identity])
return ( <button onClick={login} disabled={isLoggingIn}> {isLoggingIn ? "Signing in..." : "Sign in with Ethereum"} </button> )}