Thirteen explorers. One shape.

Etherscan, Blockscout, Mempool, Solscan, Koios and eight more behind one TypeScript contract. Balances, transactions, token transfers, contracts, tokens, gas and blocks on 23 chains, amounts as exact strings, and a provider picked for you from the keys you have. A library, a CLI, an MCP server and Pi and OMP extensions, all reading through the same code.

or look something up

13
providers
23
chains
8
operations
9
agent tools

Balances

An address in, one Balance out

getBalance(address, chain) returns the same six fields whether the answer came from Etherscan, Mempool or an Arweave gateway. ENS names resolve first, so vitalik.eth is as good as the hex. This panel walks through 3 addresses on three chains and swaps each recorded sample for the live answer from the docs worker as it arrives.

  • balance is a string in the chain's smallest unit; balanceFormatted is the same number for humans
  • fetchedAt on every read, blockNumber and blockHash when the explorer says which block it meant, null when it does not
  • UTXO explorers add funded and spent totals; the rest leave them out rather than send zero
Open the explorer
vitalik.ethsample

balanceFormatted

6.71215016 ETH

balance "6 712 150 161 831 460 931" · a string in the smallest unit

chain
ethereum · Ethereum
provider
blockscout · Blockscout
address
0xd8dA6BF269…7aA96045
blockNumber
null
fetchedAt
2026-09-05 15:44

Transactions

History and detail, same Transaction

getTxHistory pages through an address, getTxDetail reads one hash, and both hand back the same Transaction. The raw explorer answer rides along in raw for the day you need a field nobody normalized.

  • hash, from, to, value, fee, status and the token transfers inside, on every chain that has them
  • to is null for contract creation, never an empty string pretending to be an address
  • OP_RETURN payloads on Bitcoin, Litecoin and Pepecoin come as hex, plus text when the bytes are printable
Transactions

getTxHistory{ limit: 5 }

5 rows · sample

  1. 0x18fbf4798992…f083cbab

    0xFD8d…dC48 → 0xd8dA…6045 · block 25882055 · 2026-09-01 · 0x4f414d20

    0 ETHsuccess
  2. 0xc26d92ef86e1…8395a01b

    0xFD8d…dC48 → 0xd8dA…6045 · block 25881912 · 2026-09-01 · 0x4f414d50

    0 ETHsuccess
  3. 0x9e614e2ccc3d…1d41fab8

    0x899A…3871 → 0xd8dA…6045 · block 25834334 · 2026-08-25

    0.000066 ETHsuccess
  4. 0x2a6b0fd28b54…429437ba

    0xb8aE…6B89 → 0xd8dA…6045 · block 25830440 · 2026-08-25

    0.000409 ETHsuccess
  5. 0xff3d62e0f231…e81abc1b

    0xb8aE…6B89 → 0xd8dA…6045 · block 25823663 · 2026-08-24

    0.000409 ETHsuccess

Provider selection

Keys first, keyless next, Blockscout last

Set ETHERSCAN_API_KEY and Ethereum reads go through Etherscan. Unset it and they go through Blockscout. Nothing else in your code changes. The ranking never loads a provider module, so listing candidates is free and the one that answers is the only one imported.

  • resolveProvider() ranks configured keys, then keyless providers, then anything else that serves the chain
  • withProvider() retries once on the next candidate after a RateLimitError or a PlanRestrictedError
  • An explicit --provider stays strict: a wrong chain is an UnsupportedChainError, not a silent switch
How a provider is picked

resolveProvider(undefined, "ethereum", "balances")

no keys on the worker

1Blockscout "blockscout"keyless
2Etherscan "etherscan"needs a key
3Blockchair "blockchair"optional key

10 other providers do not serve ethereum and are never asked. A rate limit or a plan restriction on the first one moves the read to the next, once.

Providers

Thirteen backends, honest about what they serve

Etherscan wants an API key and a chain id, Koios wants the address in a POST body, the Arweave gateway answers half its questions over GraphQL. Each provider keeps that to itself and maps its answers onto the shared types. A capability a backend cannot serve is missing, not stubbed with convincing nonsense.

  • capabilities is a per-provider flag set, and an optional method is absent when the flag is false
  • Aptos stays registered with no capabilities rather than hiding a fullnode RPC behind an explorer name
  • Your own backend is one class extending Provider plus an entry with its chains and a loader
All providers

Agents

Nine tools, three hosts

explorers mcp serves the tools over stdio, the Pi and OMP extensions render them in the terminal. All three go through the same withProvider() as the CLI, so the selection, the keys and the one retry behave the same wherever the call starts.

  • explorers_balance takes one address or up to twenty, and resolves ENS names before it reads
  • Every tool is read-only and says so in its annotations; nothing here signs or sends
  • A provider that cannot serve an operation answers with UnsupportedOperationError, not an empty list
MCP, Pi and OMP

toolexplorers_balance

MCP · Pi · OMP

input

{
  "address": "vitalik.eth",
  "chain": "ethereum"
}

output

{
  "provider": "blockscout",
  "data": {
    "address": "0xd8dA6BF269…7aA96045",
    "chain": "ethereum",
    "balance": "6712150161831460931",
    "balanceFormatted": "6.71215016",
    "symbol": "ETH",
    "blockNumber": null,
    "fetchedAt": "2026-09-05T15:44:39.434Z"
  }
}

One interface

Same calls, every provider

Provider is the abstract base with two required reads and six optional ones. Concrete classes implement the mappers and the explorer calls, nothing else leaks upward. A sub path import like @agntn/explorers/providers/mempool gives you one backend without the other twelve in your bundle.

  • getBalance and getTxHistory on every provider; the other six only where they are real
  • ExplorerError, HTTPError, AuthError, RateLimitError, PlanRestrictedError, NotFoundError and three more
  • API keys are stripped from every URL before an error message exists
Getting started
TSethereum.ts
import { create, resolveAddresses, resolveProvider } from "@agntn/explorers";

// blockscout: keyless, nothing to configure
const name = resolveProvider(undefined, "ethereum");
const provider = await create(name);

const [address] = await resolveAddresses("vitalik.eth", "ethereum");
const balance = await provider.getBalance(address, "ethereum");
const history = await provider.getTxHistory(address, "ethereum", { limit: 5 });

balance.balanceFormatted;  // "6.71215016" ETH, exact, a string
history[0]?.hash;          // "0x18fbf47989…83cbab", same Transaction shape from Blockscout

Start with one command

Pre-1.0, so pin exact versions. Everything here reads; nothing signs, sends or holds a key for you. Keep your explorer keys in the environment and out of your code.

@agntn/explorers·MIT license· Read-only. Addresses you type go to a public explorer API, never to a wallet.