Skip to main content

Agent quickstart

Discover → check → buy → relay → follow, as one program. It uses @corelayer/sdk for the CoRelayer calls and @multiversx/sdk-core for the agent's own key.

On devnet only

CoRelayer runs on devnet only; nothing is deployed on mainnet yet.

The program is not a sketch: it is type-checked against the API's generated types and run end to end by this site's test suite against a stand-in API and gateway, from an account with no plan to an executed transfer.

What you need​

A MultiversX keyIn a PEM file the program reads. Nothing in CoRelayer ever sees it.
USDC on the network you useUSDC-c76f1f on mainnet, USDC-350c4e on devnet. The program takes it from GET /v1/network.
The contract address of that networkYour pin, from your own configuration. Copy it from the contract page or /.well-known/corelayer.json.
EGLDNone.

The program​

agent-quickstart.ts
/**
* The agent quickstart as one program: discover → check → buy if needed → relay → follow.
*
* Configuration comes from the environment, so no key, address or host is written into the code:
*
* CORELAYER_API https://devnet-api.co-relayer.com (or a local backend)
* CORELAYER_CHAIN_ID D (1 on mainnet)
* CORELAYER_CONTRACT the CoRelayer contract of that chain — YOUR pin, from the docs or the explorer
* CORELAYER_GATEWAY https://devnet-gateway.multiversx.com (any gateway that is not CoRelayer's)
* CORELAYER_PEM path to the agent's key file
* RECEIVER who gets the tokens
* AMOUNT smallest units of the network's USDC (6 decimals: 1000000 = 1 USDC)
* BUY_TIER optional: tier to buy when the account has no entitlement (11 = Agent Metered)
* BUY_MONTHS optional, default 1 (0 for Agent Metered)
* MAX_PAY optional: the most the purchase may cost, micro-USDC
*
* node agent-quickstart.ts
*/
import { pathToFileURL } from 'node:url';
import { CoRelayerClient, type components } from '@corelayer/sdk';
import { buyPlan } from './buy-plan.ts';
import { Gateway } from './gateway.ts';
import { type RelayContext, sendToken } from './send-token.ts';
import { RelayerCheck } from './verify-relayer.ts';
import { type Wallet, walletFromPemFile } from './wallet.ts';
import { followIntent, type Outcome } from './watch-intent.ts';

type Quota = components['schemas']['Quota'];

/** Suffix for idempotency keys: one run of this program is one business action per step. */
const RUN_ID = new Date()
.toISOString()
.replace(/[^0-9]/g, '')
.slice(0, 14);

export interface QuickstartConfig {
readonly api: string;
readonly chainId: string;
readonly contract: string;
readonly gateway: string;
readonly wallet: Wallet;
readonly receiver: string;
readonly amount: bigint;
readonly buy?: {
readonly tierId: number;
readonly months: number;
readonly maxPayMicroUsdc: bigint;
};
/** Injected in tests; the global `fetch` otherwise. */
readonly fetch?: typeof globalThis.fetch;
readonly log?: (line: string) => void;
}

/** The account states in which CoRelayer serves a transaction. */
const SERVED: ReadonlySet<Quota['state']> = new Set([
'ACTIVE_CAP',
'ACTIVE_PAYG',
'RENEWAL_PENDING',
]);

/** `erd1…:41` → 41. The intent id is `<sender>:<nonce>` by definition. */
function nonceOf(intentId: string): number {
return Number(intentId.slice(intentId.lastIndexOf(':') + 1));
}

export async function runQuickstart(config: QuickstartConfig): Promise<Outcome> {
const log = config.log ?? ((line: string) => console.log(line));
const fetchOption = config.fetch === undefined ? {} : { fetch: config.fetch };
const me = config.wallet.address;

// ── 1. Discover ────────────────────────────────────────────────────────────────────────────
const bootstrap = new CoRelayerClient({ baseUrl: config.api, ...fetchOption });
const { data: network } = await bootstrap.getNetwork();
if (network.chainId !== config.chainId) {
throw new Error(
`The API serves chain ${network.chainId}; this agent is configured for ${config.chainId}.`,
);
}
if (network.contract !== null && network.contract !== config.contract) {
// The API repeats the contract address for convenience. Your pin wins; a disagreement is a
// reason to stop and find out why, not to switch.
throw new Error(`The API names contract ${network.contract}; your pin is ${config.contract}.`);
}
// From here on, the failover list is the operator's, not a guess frozen into this file.
const client = new CoRelayerClient({
baseUrl: config.api,
directHosts: network.directHosts ?? [],
...fetchOption,
});
const gateway = new Gateway({ url: config.gateway, ...fetchOption });
const context: RelayContext = {
client,
wallet: config.wallet,
gateway,
relayers: new RelayerCheck({ chainId: config.chainId, contract: config.contract, gateway }),
chainId: config.chainId,
};
log(
`chain ${network.chainId}, payment token ${network.paymentToken ?? 'not reported'}, sender ${me}`,
);

// ── 2. Check entitlement ───────────────────────────────────────────────────────────────────
const readQuota = async (): Promise<Quota> =>
(await client.transport.get<Quota>(`/v1/account/${encodeURIComponent(me)}/quota`)).data;
let quota = await readQuota();
log(`service state ${quota.state}`);

// ── 3. Buy, if there is nothing to relay on ────────────────────────────────────────────────
if (!SERVED.has(quota.state)) {
if (config.buy === undefined) {
throw new Error(`The account is ${quota.state} and no purchase is configured (BUY_TIER).`);
}
const { response, quote } = await buyPlan(
{ ...context, contract: config.contract },
{ ...config.buy, intentKey: `buy-${me}-${config.buy.tierId}-${RUN_ID}` },
);
log(
`purchase ${response.intentId}: ${response.state}, price ${quote.priceMicroUsdc} micro-USDC`,
);
// Wait for the purchase to be final before signing anything else: it used nonce n.
const bought = await followIntent(client, me, nonceOf(response.intentId));
if (bought.kind !== 'executed') throw new Error(`The purchase ended ${bought.kind}.`);
quota = await readQuota();
log(`service state ${quota.state}`);
}

// ── 4. Relay ───────────────────────────────────────────────────────────────────────────────
const sent = await sendToken(context, {
receiver: config.receiver,
amount: config.amount,
intentKey: `send-${me}-${config.receiver}-${config.amount}-${RUN_ID}`,
});
if (sent.kind === 'resign-required') {
// An agent may decide to re-sign (see resignWith), but it decides — with a bound.
throw new Error(
`Relayer ${sent.previousRelayer} became unavailable before co-signing. Nothing was sent; ` +
`re-signing on nonce ${sent.pinnedNonce} is a separate, deliberate step.`,
);
}
log(`relayed ${sent.response.intentId} (${sent.response.txHash}): ${sent.response.state}`);

// ── 5. Follow it to the outcome ────────────────────────────────────────────────────────────
const outcome = await followIntent(client, me, nonceOf(sent.response.intentId), {
onState: (intent) => log(` ${intent.state}${intent.final ? ' (final)' : ''}`),
});
log(`outcome: ${outcome.kind}`);
return outcome;
}

function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === '') throw new Error(`Set ${name}.`);
return value;
}

async function main(): Promise<void> {
const tier = process.env.BUY_TIER;
const outcome = await runQuickstart({
api: required('CORELAYER_API'),
chainId: required('CORELAYER_CHAIN_ID'),
contract: required('CORELAYER_CONTRACT'),
gateway: required('CORELAYER_GATEWAY'),
wallet: walletFromPemFile(required('CORELAYER_PEM')),
receiver: required('RECEIVER'),
amount: BigInt(required('AMOUNT')),
...(tier === undefined
? {}
: {
buy: {
tierId: Number(tier),
months: Number(process.env.BUY_MONTHS ?? '1'),
maxPayMicroUsdc: BigInt(required('MAX_PAY')),
},
}),
});
process.exitCode = outcome.kind === 'executed' ? 0 : 1;
}

if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

It is built from the pieces documented on their own pages:

StepModulePage
Discover, refuse the wrong chainsend-token.ts → connectTypeScript
Check entitlementGET /v1/account/{erd}/quotaCheck before you send
Buy, if there is nothing to relay onbuy-plan.tsBuying a plan
Relay, with the relayer verified on chainsend-token.ts, verify-relayer.tsSign and relay
Follow to the outcomewatch-intent.tsWatch an intent
The keywallet.tsSign and relay

The relay step on its own​

An agent that already has a plan needs only the relay. relayOnce asks for a relayer with a presence proof your agent signs, builds the transaction for that relayer, has your agent sign it once and submits it. This is the code co-relayer.com shows for an agent that pays for itself:

agent-relay.ts
import { relayOnce } from '@corelayer/sdk';

type Opts = Parameters<typeof relayOnce>[0];
// The agent's own key: its address and the two things it signs.
type AgentKey = Pick<Opts, 'sender' | 'signProof' | 'signOnce'>;
// The client, the transaction builder and one key per action.
type Job = Pick<Opts, 'client' | 'buildTransaction' | 'intentKey'>;

// Your agent signs once, with its own key. No EGLD needed.
export function relayAsAgent(agent: AgentKey, job: Job) {
return relayOnce({ ...job, ...agent });
}

agent is your agent's key as relayOnce takes it: sender is its address, signProof signs the presence proof and signOnce signs the transaction, once.

To check the relayer on chain before your agent signs, pass verifyRelayer a verifier from createRelayerVerifier: see Verify a relayer.

Running it​

The program runs on Node 24, next to the example files it imports and with @corelayer/sdk and @multiversx/sdk-core installed:

CORELAYER_API=https://devnet-api.co-relayer.com \
CORELAYER_CHAIN_ID=D \
CORELAYER_CONTRACT=<the devnet contract, from your configuration> \
CORELAYER_GATEWAY=https://devnet-gateway.multiversx.com \
CORELAYER_PEM=./agent.pem \
RECEIVER=erd1… AMOUNT=1000000 \
BUY_TIER=11 BUY_MONTHS=0 MAX_PAY=1000000 \
node examples/agent-quickstart.ts

The SDK is not on npm yet. Every call it makes is a documented route, so until it is, plain fetch can take its role: Pay for your users shows a relay with no SDK.

What it does, and why each check is there​

It compares chain ids before anything else. CoRelayer's wallets have the same addresses on devnet and mainnet, and the contract address may coincide too. An address never tells you which network you are on; the chain id does.

It stops if the API names a different contract. The API repeats the contract address for convenience. Your pin wins, and a disagreement is a reason to find out why — never to switch.

It takes the failover hosts from the server. GET /v1/network lists the regional hosts the transport falls back to, so the list is the operator's, not a guess in your code.

It buys before it signs anything else. A purchase and a payload both come from the same account and both use a nonce. So it buys, waits for the purchase to be final, and only then builds the transfer — at the next nonce. A payload signed first at nonce n would be dead the moment the purchase took n.

It verifies the relayer on chain before its one signature. Through a gateway that is not CoRelayer's, against the pinned contract. (Verify a relayer)

It does not re-sign on its own. If the relayer becomes unusable before co-signing, it stops and says so; re-signing is a separate, deliberate step with a bound. (Handle a re-sign request)

It follows the transaction to a final state — and treats a timeout as "unknown", never as a failure.

Tier 11, Agent Metered​

The example buys Agent Metered when the account has no entitlement: no period, no cap, every Relay Unit billed at the pay-as-you-go price from an escrow. depositAndSubscribe with tier 11 and months = 0 turns the whole payment into credits and pay-as-you-go escrow in one transaction, and the purchase itself is relayed free of charge — which is what makes it possible with zero EGLD.

Over x402 instead​

If your agent speaks HTTP 402, it can buy the same plan without the prepare route: x402 has the complete client. The same ordering rule applies — pay first, then sign the payload.

The checklist​

Before your agent handles real money, make sure it does each of these:

  • Compare the chain id before every signature.
  • Pin the contract address in your own configuration, keyed by chain id.
  • Verify the relayer is active on chain, through a node that is not ours.
  • Sign exactly once per action.
  • Use one idempotency key per business action.
  • Retry by re-sending identical bytes; never re-sign.
  • Respect resign; never infer it from a status code.
  • Handle unknown error codes by status and retryable.
  • Never write a signed transaction to a log or a transcript.