Setting Up @solana/web3.js and @solana/spl-token for Agent Tools
Scaffold a small TypeScript module that agents call for Solana reads and writes, with cluster pinning, typed connections, and SPL Token helpers ready for tool wrappers.
Summary
Install @solana/web3.js and @solana/spl-token, centralize Connection + payer loading, and expose narrow functions your agent framework can register as tools - without leaking secrets into model context.
Recipe
- Create a Node package and install Solana client libraries plus validation helpers.
- Pin cluster via env (
devnetfirst) and construct one sharedConnection. - Load signing keys only in the tool runtime (env, KMS, or custody API later).
- Add read helpers: balance, token accounts, recent signatures.
- Add write scaffolding hooks (transfer builders) behind explicit feature flags.
- Register functions with your agent toolkit using strict JSON schemas.
- Smoke-test on devnet with a disposable keypair before any mainnet config exists.
Working Example
npm init -y
npm install @solana/web3.js @solana/spl-token bs58 zod
npm install -D typescript tsx @types/node
npx tsc --init// src/solana/client.ts
import {
Connection,
Keypair,
PublicKey,
clusterApiUrl,
LAMPORTS_PER_SOL,
} from "@solana/web3.js";
import {
getAssociatedTokenAddressSync,
getAccount,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import bs58 from "bs58";
export type ClusterName = "devnet" | "testnet" | "mainnet-beta";
export function requiredCluster(): ClusterName {
const c = (process.env.SOLANA_CLUSTER ?? "devnet") as ClusterName;
if (!["devnet", "testnet", "mainnet-beta"].includes(c)) {
throw new Error(`Unknown SOLANA_CLUSTER=${c}`);
}
return c;
}
export function makeConnection(commitment: "confirmed" | "finalized" = "confirmed") {
const cluster = requiredCluster();
const url =
process.env.SOLANA_CLUSTER_URL ??
clusterApiUrl(cluster === "mainnet-beta" ? "mainnet-beta" : cluster);
return { cluster, connection: new Connection(url, commitment) };
}
export function loadKeypairFromEnv(name = "AGENT_PAYER_SECRET_BASE58"): Keypair {
const raw = process.env[name];
if (!raw) throw new Error(`${name} is required for signing tools`);
return Keypair.fromSecretKey(bs58.decode(raw));
}
export async function solBalance(connection: Connection, owner: PublicKey) {
const lamports = await connection.getBalance(owner, "confirmed");
return { lamports, sol: lamports / LAMPORTS_PER_SOL };
}
export async function splTokenBalance(
connection: Connection,
owner: PublicKey,
mint: PublicKey,
) {
const ata = getAssociatedTokenAddressSync(mint, owner);
try {
const acct = await getAccount(connection, ata);
return {
ata: ata.toBase58(),
amount: acct.amount.toString(),
mint: mint.toBase58(),
programId: TOKEN_PROGRAM_ID.toBase58(),
};
} catch {
return { ata: ata.toBase58(), amount: "0", mint: mint.toBase58(), missing: true };
}
}// src/solana/tools.ts - framework-agnostic tool handlers
import { z } from "zod";
import { PublicKey } from "@solana/web3.js";
import { makeConnection, loadKeypairFromEnv, solBalance, splTokenBalance } from "./client";
const GetBalanceIn = z.object({
address: z.string().min(32).max(64),
});
export async function toolGetSolBalance(raw: unknown) {
const { address } = GetBalanceIn.parse(raw);
const { cluster, connection } = makeConnection();
const bal = await solBalance(connection, new PublicKey(address));
return { cluster, address, ...bal };
}
const GetTokenBalIn = z.object({
owner: z.string(),
mint: z.string(),
});
export async function toolGetSplBalance(raw: unknown) {
const args = GetTokenBalIn.parse(raw);
const { cluster, connection } = makeConnection();
const result = await splTokenBalance(
connection,
new PublicKey(args.owner),
new PublicKey(args.mint),
);
return { cluster, owner: args.owner, ...result };
}
// Signing tools should import loadKeypairFromEnv only when the tool is enabled.
export function signingEnabled() {
return process.env.AGENT_CHAIN_WRITES === "1";
}Wire toolGetSolBalance / toolGetSplBalance into LangGraph, OpenAI Agents SDK, CrewAI, or your custom registry as pure functions with JSON-schema mirrors of the Zod objects.
Deep Dive
Why two packages
| Package | Role in agent tools |
|---|---|
@solana/web3.js | RPC Connection, keys, transactions, system transfers, confirms |
@solana/spl-token | ATAs, mint/account layout helpers, token transfer instructions |
Agents that only move SOL still benefit from web3.js alone.
Agents that price, escrow, or pay in SPL tokens need both.
Package majors evolve (including the web3.js 2.x / Kit line). Pin versions in lockfiles and verify at build against current Solana docs when upgrading.
Module boundaries that keep agents safe
agent loop (model)
-> tool schemas (no secrets)
-> tool handlers (validate args)
-> solana/client (connection, optional signer)
-> RPC / custody APIDo not pass a global Keypair into every tool.
Read tools should not import signing material at all.
RPC and commitment
Public devnet/mainnet URLs are fine for learning and light tests.
Production agents usually need a dedicated RPC provider, retries with jitter, and explicit commitment for confirms.
Standardize on one commitment for "business success" (confirmed or finalized) so every tool agrees when the agent may continue.
Token program realities
- Associated Token Accounts (ATAs) are the normal destination for SPL balances.
- Transfers fail if the destination ATA is missing unless you create it first (payer pays rent).
- Token-2022 / extension mints may need different program IDs; detect mint owner rather than hardcoding forever (verify at build).
Agent registration tips
- Tool names should be verbs the model can plan with:
getSolBalance,getSplTokenBalance,transferSOL. - Tool descriptions must state cluster, units (SOL vs lamports, raw token amount vs UI amount), and that writes require
intentId. - Return JSON with
cluster, ids, and signatures so traces stay searchable.
Gotchas
- Lamports vs SOL vs UI token amounts. Models invent decimals; convert in code and document units in the schema description.
- Wrong cluster URL with right key. A mainnet key on devnet RPC is confusing; a devnet mindset on mainnet is expensive. Pin both.
- Secret keys in tool responses. Never echo env secrets or full keybytes in results the model will re-read.
- Single shared Connection across concurrent tools. Usually fine; still set timeouts and avoid blocking the event loop on long confirms without async structure.
- Assuming ATA exists.
getAccountthrows when missing; handle and optionally exposecreateAtaas a separate gated tool. - Lockfile drift.
@solana/web3.jsand@solana/spl-tokenmust stay compatible; upgrade together in CI. - Browser vs server. Agent signing tools belong on a trusted server runtime, not in an end-user browser bundle with embedded secrets.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
@solana/web3.js + @solana/spl-token (this recipe) | Mature examples, wide docs | API surface is large; versions move |
@solana/kit / web3.js 2.x style stack | Modern composable APIs | Different patterns; rewrite cost (verify at build) |
| Wallet-adapter only (user signs) | Agent never holds keys | Needs interactive user; not server autonomy |
| Custody API (Fireblocks, etc.) | Policy + HSM/MPC | Vendor integration; not free |
Python (solana-py / solders) | Fits Python agent codebases | Ecosystem examples skew TS; keep parity tests |
FAQs
Should every agent process create its own Connection?
One connection factory per process is enough for most bots. Create fresh clients if you multi-tenant different RPC keys or clusters in one process.
Do I need spl-token for SOL-only agents?
No. Add it when you touch mints, ATAs, or token transfers. Many agents start SOL-only and add SPL later.
How do I express decimals safely to the model?
Have the tool accept human units only after you fix decimals in code, or accept raw integer amounts only. Mixing both in one field invites 10^6 errors.
Can I use the same scaffolding for mainnet?
Yes, after devnet proof, by changing env - not by forking code. Keep write enable flags and custody stricter on mainnet.
Where do PDAs and custom programs fit?
Add a programs/ module for IDL-driven builders (Anchor clients, etc.). Still validate args and keep signing behind the same policy gate.
Is clusterApiUrl production-grade?
It is convenient for demos. Production agents should use provider URLs with auth, monitoring, and SLOs.
How should tools report errors to the agent?
Return structured { status: "error", code, message } for expected failures (bad pubkey, insufficient funds). Throw only for programmer errors. That keeps the loop recoverable.
What about rate limits on public RPC?
They will bite agent loops that poll aggressively. Back off, cache read results briefly, and use webhooks or subscription APIs where appropriate.
Related
Related: Blockchain & On-Chain Agents Basics
Related: Building transferSOL and createTransaction Agent Tools
Related: Key Management for Agent-Controlled Wallets: Fireblocks and Alternatives
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.