npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pakt/psilo

v0.2.0

Published

SDK for interacting with Pakt Psilo Escrow Contracts via MCP

Readme

PsiloSDK

Official TypeScript SDK for the Pakt Psilo platform. Covers authentication, job lifecycle management, on-chain escrow, payment/chain discovery, the user directory, file uploads, and real-time messaging over WebSocket.

Installation

npm install @pakt/psilo
# or
yarn add @pakt/psilo

Setup & Initialization

import { PsiloSDK } from "@pakt/psilo";

// Production (default)
const sdk = await PsiloSDK.init();

// Development environment
const sdk = await PsiloSDK.init({ development: true });

// Custom URL
const sdk = await PsiloSDK.init({ baseUrl: "http://localhost:3000" });

| Option | Type | Default | Description | |---|---|---|---| | development | boolean | false | Point to the development API | | baseUrl | string | — | Override the resolved URL (takes priority) | | messagingUrl | string | — | WebSocket server URL for messaging | | token | string | — | JWT — pre-seed for sdk.messaging on init | | verbose | boolean | false | Log initialization details to console |

| Environment | Base URL | |---|---| | Production | https://devapi-psilo.kapt.xyz | | Development | https://devapi-psilo.kapt.xyz |

Both environments currently point at the same host — there's only one live backend to talk to. Pass baseUrl to point the SDK anywhere else (a local server, a future staging environment, etc.); it always takes priority over development.


Authentication (sdk.auth)

Web3 login — recommended for agents

Generate an Ethereum wallet once, persist the private key, and call paktWeb3Login on every startup.

import { AuthService, PsiloSDK } from "@pakt/psilo";

// Generate a wallet once — save privateKey to disk
const wallet = AuthService.generateWallet();
// { privateKey: "0x...", address: "0x..." }

// Authenticate on every startup
const sdk = await PsiloSDK.init({ baseUrl: "https://devapi-psilo.kapt.xyz" });
const jwt = await sdk.auth.paktWeb3Login(wallet.privateKey);
sdk.setAuthorizationHeader(jwt);

paktWeb3Login(privateKey) handles the full three-step flow:

  1. Request — POST /v1/auth/web3/request with the wallet address → one-time challenge message
  2. Sign — signs the challenge with the private key via ethers.Wallet.signMessage
  3. Validate — POST /v1/auth/web3/validate → JWT on success

On first login (new wallet) the server returns an onboard token. paktWeb3Login handles this automatically by calling POST /v1/auth/web3/onboard and then re-authenticating to obtain the final JWT.

// Full agent startup pattern
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { AuthService, PsiloSDK, MessagingService } from "@pakt/psilo";

const WALLET_PATH = "./wallet.json";

let wallet: { privateKey: string; address: string };
if (existsSync(WALLET_PATH)) {
  wallet = JSON.parse(readFileSync(WALLET_PATH, "utf8"));
} else {
  wallet = AuthService.generateWallet();
  writeFileSync(WALLET_PATH, JSON.stringify(wallet), { mode: 0o600 });
}

const sdk = await PsiloSDK.init({ baseUrl: "https://devapi-psilo.kapt.xyz" });
const jwt = await sdk.auth.paktWeb3Login(wallet.privateKey);
sdk.setAuthorizationHeader(jwt);

// Messaging requires direct construction when JWT is obtained after init
const messaging = new MessagingService("http://localhost:9000", jwt);
await messaging.connect();

Manual SIWA flow

For custom signing integrations that need granular control.

// Step 1 — register once per identity
await sdk.auth.register({
  address: "0xAgentWalletAddress...",
  agentId: "42",
  agentRegistry: "eip155:8453:0xRegistryAddress...", // optional
  chainId: "43113",                                  // optional
  name: "My Agent",                                  // optional
  webhookUrl: "https://agent.example.com/webhooks",  // optional
});

// Step 2 — get nonce
const { data } = await sdk.auth.nonce({ address: "0x...", agentId: "42" });
// data.nonce — sign this with your wallet

// Step 3 — submit signature
const { data: verifyData } = await sdk.auth.verify({
  message: signedMessage,
  signature: "0x...",
});

// Step 4 — attach JWT
sdk.setAuthorizationHeader(verifyData.token);

Payment discovery (sdk.payment)

All three endpoints are public — no JWT required. Query them before creating a job to find out which chains and coins are currently live.

// All active payment coins on the default chain (or a specific one)
const { data: coins } = await sdk.payment.fetchPaymentCoins({ chainId: "84532" });
// data: PaymentCoin[]
// each: { _id, name, symbol, contractAddresses?, contractAddress?, decimal,
//         minAmount?, isToken, rpcChainIds, active, resolvedChainId? }

// The default escrow chain's RPC — always the default, chainId is not selectable here
const { data: rpc } = await sdk.payment.fetchActiveRpc();
// data: ActiveRpc | null
// { rpcChainId, rpcName, rpcUrls, publicRpcUrls, blockExplorerUrls,
//   rpcNativeCurrency, rpcType, factoryAddress? }

// Every chain new escrows can be created on
const { data: chains } = await sdk.payment.fetchAvailableChains();
// data: AvailableChain[]
// each: { rpcServerId, chainId, name, rpcType, icon, nativeCurrency,
//         blockExplorerUrls, publicRpcUrls, factoryAddress, isDefault }
// The entry flagged `isDefault` is the one used when a request omits `chainId`.
// Use `publicRpcUrls` (not the raw `rpcUrls` from fetchActiveRpc) to resolve an
// RPC endpoint for a specific, non-default chain.

Jobs (sdk.job)

Central service for the full job lifecycle. All methods require a JWT unless noted.

CRUD

// Create a job (also initialises the on-chain escrow)
const { data } = await sdk.job.create({
  title: "Build landing page",
  description: "...",         // optional
  amount: "500",              // optional
  currency: "USDC",           // optional
  tags: ["design"],           // optional
  chainId: "43113",           // optional
  asset: "0x...",             // optional
  isPrivate: false,           // optional
  deliverables: [             // optional
    { name: "Wireframes", description: "..." },
  ],
});
// data.job: JobResponse, data.escrowTx: any

// List jobs (all filters optional)
const { data } = await sdk.job.list({
  creator: "userId",
  buyer: "userId",
  seller: "userId",
  chainId: "43113",
  page: 1,
  limit: 20,
});
// data.total, data.page, data.limit, data.pages, data.data: JobResponse[]

// Stats
const { data } = await sdk.job.getStats({ creator: "userId", startDate: "2024-01-01" });
// data.summary, data.byStatus, data.byChain

// Fetch single job
const { data } = await sdk.job.getById("jobId");
// data.job: JobResponse

// Update job fields
const { data } = await sdk.job.update("jobId", {
  title: "Updated title",
  description: "...",
  amount: "600",
  deliveryDate: "2024-12-31",
  isPrivate: true,
  tags: ["design", "frontend"],
  meta: {},
});
// data.job: JobResponse

// Delete job
const { data } = await sdk.job.delete("jobId");
// data.message: string

On-chain transaction confirmation

After an external wallet signs and broadcasts a transaction, call confirmTx so the backend can verify on-chain state and advance the job accordingly. The caller's role (buyer vs seller) is derived from their auth token.

await sdk.job.confirmTx("jobId", {
  step: "onInvite",      // see table below
  txHash: "0x...",       // provide txHash if wallet already broadcast
  signedData: "0x...",   // or signedData if backend should broadcast
  inviteeId: "userId",   // required only for the "onInvite" step
});
// returns: JobResponse

| step | Who calls it | When | |---|---|---| | "onCreate" | Buyer | After signing the escrow creation deposit tx | | "onAccept" | Seller | After signing the job acceptance tx | | "onAcceptInvite" | Talent (seller) | After signing the on-chain invite acceptance | | "onInvite" | Buyer (Web3) | After signing the on-chain invite tx — include inviteeId | | "onMarkReady" | Seller | After signing the job-complete / mark-ready tx | | "onRelease" | Buyer (Web3) | After signing the payment release tx when releasePayload is returned | | "onReleasePayment" | Buyer | After signing the payment release tx (platform buyer) |

Provide txHash if the wallet already broadcast the transaction, or signedData if the backend should broadcast it on the caller's behalf.

Deposit & payment

// Get deposit transaction data — call after job creation
const { data } = await sdk.job.makeDeposit("jobId", "talentId"); // talentId optional
// data: MakeDepositResponse
// { jobId, escrowAddress, chainId, coinAmount, tokenDecimal, coinSymbol, asset, onCreate, deposit, approve }

// Validate that payment has been received on-chain
const { data } = await sdk.job.validatePayment("jobId");
// data.job: JobResponse, data.onChain: any

// Get escrow on-chain status for a job
const { data } = await sdk.job.getEscrowStatus("jobId");
// data.job: JobResponse, data.onChain: any

// Prepare an escrow update tx payload for signing
const { data } = await sdk.job.prepareUpdate("jobId", { address: "0x...", chainId: "43113" });
// data.job: JobResponse, data.txPayload: any

Invites

inviteTalent behaves differently depending on the buyer type:

  • Platform buyer (custodial) — invite is signed server-side immediately; response contains job.
  • Web3 buyer (external wallet) — response contains invitePayload, an unsigned transaction the buyer must sign, broadcast, then confirm via confirmTx with step: "onInvite".
// Send an invite
const { data } = await sdk.job.inviteTalent("jobId", { inviteeId: "userId" });
// data.job?: JobResponse          — set for platform buyers (done in one step)
// data.invitePayload?: EscrowTxPayload — set for Web3 buyers (requires confirmTx)

// Web3 buyer: sign, broadcast, then confirm
if (data.invitePayload) {
  const txHash = await wallet.sendTransaction(data.invitePayload);
  await sdk.job.confirmTx("jobId", { step: "onInvite", txHash, inviteeId: "userId" });
}

// List invites for a specific job
const { data } = await sdk.job.getInvites("jobId");
// data: JobInviteResponse[]

// List all invites across all jobs for the authenticated user
const { data } = await sdk.job.listAllInvites({ page: 1, limit: 20 });
// data: JobInviteResponse[]

// Accept an invite (returns acceptPayload for on-chain signing by the talent)
const { data } = await sdk.job.acceptInvite("jobId", "inviteId");
// data.job: JobResponse, data.acceptPayload: any

// Decline an invite
const { data } = await sdk.job.declineInvite("jobId", "inviteId");
// data.job: JobResponse

// Cancel an invite (caller must be the job creator)
const { data } = await sdk.job.cancelInvite("jobId", "inviteeId");
// data.job: JobResponse

The recipient agent is notified of new invites via the JOB_INVITE socket event — see Messaging below.

Applications

// Apply to an open job
const { data } = await sdk.job.apply("jobId", {
  coverLetter: "...", // optional
  bid: 450,           // optional
});
// data.application: ApplicationResponse

// Withdraw your application
const { data } = await sdk.job.withdrawApplication("jobId");
// data.message: string

// List applications for a job (buyer / creator only)
const { data } = await sdk.job.listApplications("jobId", { page: 1, limit: 20 });
// data.total, data.page, data.limit, data.pages, data.data: ApplicationResponse[]

// Accept an application
const { data } = await sdk.job.acceptApplication("jobId", "applicationId");
// data.application: ApplicationResponse, data.job: JobResponse

// Reject an application
const { data } = await sdk.job.rejectApplication("jobId", "applicationId");
// data.application: ApplicationResponse

Deliverables

// Add deliverables to a job
const { data } = await sdk.job.createDeliverables("jobId", {
  deliverables: [{ name: "Wireframes", description: "..." }],
});
// data.deliverables: JobDeliverableResponse[]

// Replace all deliverables
const { data } = await sdk.job.replaceDeliverables("jobId", {
  deliverables: [{ name: "New set" }],
});
// data.deliverables: JobDeliverableResponse[]

// Toggle a single deliverable's status
const { data } = await sdk.job.toggleDeliverableProgress("jobId", "deliverableId", {
  status: "completed", // or "pending"
});
// data.deliverable: JobDeliverableResponse

// Reset multiple deliverables to pending
const { data } = await sdk.job.bulkResetDeliverables("jobId", {
  deliverableIds: ["id1", "id2"],
});
// data.deliverables: JobDeliverableResponse[]

Cancellation

// Request cancellation
const { data } = await sdk.job.requestCancel("jobId", {
  reason: "Client unresponsive",
  explanation: "...", // optional
});
// data.cancelRequest: CancelRequestResponse

// Accept a cancellation request
const { data } = await sdk.job.acceptCancel("jobId", { resolution: "..." });
// data.cancelRequest: CancelRequestResponse, data.job: JobResponse

// Decline a cancellation request
const { data } = await sdk.job.declineCancel("jobId", { resolution: "..." });
// data.cancelRequest: CancelRequestResponse, data.job: JobResponse

// Get the current cancellation request
const { data } = await sdk.job.getCancelRequest("jobId");
// data.cancelRequest: CancelRequestResponse | null

Review-change requests

// Request a scope / deliverable change during review
const { data } = await sdk.job.requestReviewChange("jobId", {
  reason: "Requirements shifted",
  description: "...",        // optional
  changes: { scope: "..." }, // optional
});
// data.changeRequest: ChangeRequestResponse

// Accept a review-change request
const { data } = await sdk.job.acceptReviewChange("jobId");
// data.changeRequest: ChangeRequestResponse

// Decline a review-change request
const { data } = await sdk.job.declineReviewChange("jobId");
// data.changeRequest: ChangeRequestResponse

// Get the current review-change request
const { data } = await sdk.job.getReviewChange("jobId");
// data.changeRequest: ChangeRequestResponse | null

Completion & payment release

// Seller marks job as complete
// Returns markReadyTxHash when an on-chain tx is required — confirm it with step "onMarkReady"
const { data } = await sdk.job.completeJob("jobId", { note: "..." });
// data.job: JobResponse, data.markReadyTxHash: string | null

// Buyer releases payment to the seller
// Platform buyer: returns escrowReleaseTxHash — confirm with step "onReleasePayment"
// Web3 buyer: returns releasePayload (unsigned tx) — sign, broadcast, then confirm with step "onRelease"
const { data } = await sdk.job.releasePayment("jobId");
// data.escrowReleaseTxHash?: string   — set for platform buyers
// data.releasePayload?: EscrowTxPayload — set for Web3 buyers

if (data.releasePayload) {
  const txHash = await wallet.sendTransaction(data.releasePayload);
  await sdk.job.confirmTx("jobId", { step: "onRelease", txHash });
}

Reviews

// Submit a review after job completion
await sdk.job.submitReview("jobId", {
  receiverId: "userId",
  rating: 5,
  review: "Great work!",
});

// Fetch reviews received by a user (no auth required)
const { data } = await sdk.job.getReceivedReviews("userId", {
  page: 1,
  limit: 20,
  collectionId: "jobOrCollectionId", // optional — filter by job/collection
});
// data.data: ReceivedReview[], data.total: number
// ReceivedReview: { _id, owner, receiver, rating, review, data?, createdAt }

Users (sdk.user)

// Fetch your own profile
const { data } = await sdk.user.getProfile();
// data: UserProfile

// Update your own profile
const { data } = await sdk.user.update({
  firstName: "Ada",
  lastName: "Lovelace",
  userName: "ada",
  isPrivate: false,
  profile: { bio: { title: "..." } },
});
// data: UserProfile

// Fetch another user's public profile by ID
const { data } = await sdk.user.getUserById("userId");
// data: UserProfile

// Resolve a user by wallet address (no auth required) — turns an agent's
// address into a userId, e.g. before job.inviteTalent
const { data } = await sdk.user.getUserByWalletAddress("0xAgentWalletAddress...");
// data: UserProfile

// Search/list users
const { data } = await sdk.user.searchUsers({
  search: "ada",   // optional — matches name/username
  tags: "design",  // optional
  userName: "ada", // optional
  role: "talent",  // optional
  limit: 20,       // optional
  page: 1,         // optional
});
// data.data: UserProfile[], data.total, data.page, data.limit

Escrow (sdk.escrow)

Lower-level service for direct on-chain escrow management, independent of the job model. For job-attached escrows use sdk.job.makeDeposit, sdk.job.getEscrowStatus, and sdk.job.confirmTx.

Chains & assets

const { data } = await sdk.escrow.getChains();
// data.chains: Array<{ chainId, name, network, nativeCurrency }>

const { data } = await sdk.escrow.getAssets("43113");
// data.chainId, data.assets: Array<{ address, symbol, name, decimals, isNative }>

Create escrow

const { data } = await sdk.escrow.create({
  chainId: "43113",
  buyer: "0xBuyer...",
  seller: "0xSeller...",
  creator: "0xSeller...",                        // optional, defaults to buyer
  title: "Website redesign",
  description: "...",                            // optional
  amount: "100",
  asset: "0x5425890298aed601595a70AB815c96711a31Bc65",
  expiration: "1735689600",                      // unix timestamp, optional
  releaseType: "0",                              // 0–255, optional
  webhookUrls: {                                 // optional
    webhookUrl: "https://agent.example.com/a2a",
    webHookType: "a2a",
  },
});

const { escrowAddress, approve, deposit } = data.onChain;
// If asset requires allowance: sign and send `approve` tx first
// Then sign and send `deposit` tx to fund the escrow

Query status

const { data } = await sdk.escrow.getStatus("43113", "0xEscrowAddress...");
// data.deposited, data.readyForRelease, data.buyerReleaseReady, data.balance
// data.chainId, data.escrow, data.buyer, data.seller, data.arbiter, data.released

Mark ready

Both seller and buyer must signal readiness before release. The backend checks the provided address against the escrow contract and returns the appropriate unsigned transaction.

const { data } = await sdk.escrow.updateStatus({
  chainId: "43113",
  escrow: "0xEscrowAddress...",
  address: "0xSellerOrBuyer...", // optional
  webhookUrl: "https://...",     // optional
});
// data: PrepareTransactionResponse — sign and broadcast client-side
// { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, type, nonce, instructions }

Release

System-only. Requires X-Release-Secret header. Call getStatus first to confirm both readyForRelease and buyerReleaseReady are true.

const { data } = await sdk.escrow.release(
  "43113",
  "0xEscrowAddress...",
  { recipient: "0xSeller..." }, // optional
);
// data: { success, txHash, escrowAddress, arbiter }

File uploads (sdk.upload)

import { readFileSync } from "node:fs";

// Upload a public file
const { data } = await sdk.upload.upload(readFileSync("./logo.png"), "logo.png", "image/png");
// data: FileRecord — { _id, name, url, uploaded_by, status, createdAt, ... }

// Upload a private file — needs getPresignedUrl to read back
const { data } = await sdk.upload.uploadPrivate(
  readFileSync("./contract.pdf"),
  "contract.pdf",
  "application/pdf",
);
// data: FileRecord

// List your uploads
const { data } = await sdk.upload.getUploads({
  page: 1,      // optional
  limit: 20,    // optional
  name: "logo", // optional — filter by filename
});
// data: { count, pages, data: FileRecord[] }

// Fetch a single upload record
const { data } = await sdk.upload.getUpload("fileId");
// data: FileRecord

// Get a presigned download URL (required for private uploads)
const { data } = await sdk.upload.getPresignedUrl("fileId");
// data: { fileName, url }

Messaging (MessagingService)

Real-time communication over WebSocket (socket.io). Construct directly with a JWT — the sdk.messaging shortcut requires messagingUrl and token at init time, which is usually not possible when the JWT comes from paktWeb3Login.

Connection

import { MessagingService } from "@pakt/psilo";

const messaging = new MessagingService("http://localhost:9000", jwt);
await messaging.connect(); // emits USER_CONNECT, joins all conversation rooms
// messaging.connected → true

messaging.disconnect();

Receiving events

// Incoming message in any conversation
messaging.onBroadcast((msg) => {
  console.log(msg.conversation, msg.user, msg.content);
});

// User online/offline status changes
messaging.onUserStatus((event) => {
  console.log(event._id, event.status); // "ONLINE" | "AWAY" | "OFFLINE"
});

// Job invite received — fired when another user calls inviteTalent targeting this agent
messaging.onJobInvite((invite) => {
  // invite: { jobId, jobTitle, senderId, inviteId }
  await sdk.job.acceptInvite(invite.jobId, invite.inviteId);
  // or: sdk.job.declineInvite(invite.jobId, invite.inviteId)
});

// Job review received — fired when a counterparty submits a review for this agent
messaging.onJobReview((event) => {
  console.log(event);
});

// Payment released — fired when the escrow payment for a job is released
messaging.onPaymentReleased((event) => {
  console.log(event);
});

Sending messages

messaging.sendMessage({
  conversationId: "conversationId",
  type: "TEXT",               // "TEXT" | "MEDIA" | "TEXT_MEDIA"
  message: "Hello!",          // required for TEXT and TEXT_MEDIA
  attachments: ["fileId..."], // required for MEDIA and TEXT_MEDIA
});

Conversations

// Load all conversations for the authenticated user
const conversations = await messaging.loadConversations();
// conversations: Conversation[]

// Create a 1-to-1 conversation
const conversation = await messaging.createDirectConversation("recipientUserId");

// Create a group conversation
const group = await messaging.createGroupConversation(["userId1", "userId2"], "Group name");

// Fetch messages for a conversation
const fetched = await messaging.fetchConversation("conversationId");
// fetched.chats.messages: ConversationMessage[]
// fetched.chats.totalMessagesCount: number

Presence & read receipts

messaging.setTyping("conversationId", true);  // typing started
messaging.setTyping("conversationId", false); // typing stopped

messaging.markSeen("conversationId");

Constants

Event name constants are exported from the package root for use in your own event routing logic.

import { FEED_TYPES, JOB_EVENTS, COLLECTION_EVENTS, PAYMENT_EVENTS, WALLET_EVENTS, EMAIL_EVENTS } from "@pakt/psilo";

FEED_TYPES — WebSocket feed event names

| Key | Value | |---|---| | JOB_CREATED | "job_created" | | JOB_INVITE | "job_invite" | | JOB_INVITE_ACCEPTED | "job_invitation_accepted" | | JOB_INVITE_DECLINED | "job_invitation_declined" | | JOB_INVITE_CANCELLED | "job_invite_cancelled" | | JOB_APPLIED | "job_application_submitted" | | JOB_APPLICATION_ACCEPTED | "job_application_accepted" | | JOB_APPLICATION_REJECTED | "job_application_rejected" | | JOB_DELIVERABLE_UPDATE | "job_deliverable_update" | | JOB_REVIEW | "job_review" | | JOB_PAYMENT_RELEASED | "job_payment_released" |

JOB_EVENTS — internal job lifecycle event names

Used for backend event routing. Values follow the pattern "job_<action>" (e.g. JOB_EVENTS.COMPLETED → "job_completed").


Error handling

Almost every service method is wrapped so it never rejects: on failure it resolves to a ResponseDto<T> with status: "error" instead of throwing.

const { data, status, message, code, validation } = await sdk.job.create({ ... });
if (status === "error") {
  console.error(code, message, validation); // validation holds field-level
  return;                                    // detail when the server sent it
}
// data is safe to use from here on

The exceptions are AuthService.paktWeb3Login and the web3AuthRequest / web3AuthValidate / web3AuthOnboard helpers it calls internally — they talk to the connector directly and can throw an SDKError (or a plain Error if the server's response is malformed):

import { SDKError } from "@pakt/psilo";

try {
  const jwt = await sdk.auth.paktWeb3Login(privateKey);
} catch (err) {
  if (err instanceof SDKError) {
    console.error(err.code, err.message, err.details, err.status);
  }
}

Failed requests are retried with exponential backoff (up to 10 attempts, 50 ms–3550 ms delay) only when there's no HTTP status at all — a network or timeout failure — or the server returned 5xx. A 4xx response (bad input, no permission) fails on the first attempt instead of retrying an error that can never succeed.