Headless SDK
@recoilpay/intent-core is the whole intent flow with no UI. It works in any framework, or none: Vue, Svelte, Angular, vanilla JS, or a Node script. The widget and the React component are both built on it.
npm install @recoilpay/intent-core viem
The flow in five lines
import { createIntentSession, viemWallet } from '@recoilpay/intent-core';
const session = createIntentSession({ hfAccessToken }); // your Hugging Face token; quotes go to api.recoilpay.com
session.subscribe((state) => render(state)); // called after every change
await session.run('swap 10 USDC on base sepolia for USDC on op sepolia');
session.setWallet(viemWallet(walletClient)); // any time; a waiting flow continues
await session.confirm(); // switch chain → approve → sign → submit → track
A session is a small store. run, setWallet, confirm and reset move it forward. getState() returns the current snapshot, and subscribe tells you when it changes. None of the methods throw: every failure ends up in the state, with a message you can show as-is.
The state
session.getState() returns an immutable snapshot, and a new object comes after every change. That works directly with Vue's shallowRef, Svelte stores, signals, or React's useSyncExternalStore.
phase
idle → parsing → quoting → quoted → switchingChain → approving → signing → submitting → tracking → done
↘ offTemplate ↘ invalid ↘ needsWallet ↘ error (from any step)
| Phase | Meaning | Show |
|---|---|---|
idle | Nothing running | The input |
parsing | Reading the sentence | A spinner |
offTemplate | Not recognisable as an intent | hint, an example sentence |
invalid | Parsed, but needs fixing | Every entry in issues |
needsWallet | Ready to quote once a wallet is set | Your connect button |
quoting | Solvers are racing | "Asking solvers…" |
quoted | A quote is ready | preview, and a confirm button |
switchingChain, approving, signing, submitting | Working through confirm() | A busy button. The wallet is prompting |
tracking | Order submitted, polling | status as a progress bar |
done | Every intent in the sentence finished | Success, with explorerUrl |
error | Something failed | error |
Fields
| Field | Type | |
|---|---|---|
ready | boolean | The live solver coverage has loaded. Disable input until it's true. |
assetsError | boolean | Coverage failed to load (offline or blocked). Call refreshAssets() to retry. |
hint | string | null | With offTemplate. |
issues | { field, kind, message }[] | With invalid. kind is missing, unknown or unsupported. |
preview | IntentPreview | null | Human-readable amounts: payAmount, paySymbol, srcChainName, receiveAmount, receiveSymbol, dstChainName, recipient, etaSeconds, solverCount, solversQueried. No base units, no addresses. |
route | 'solver' | 'direct' | null | direct means a same-chain send, made straight from the wallet. |
needsApproval | boolean | confirm() will ask for a one-time Permit2 approval first. Worth telling the user. |
orderId, status, order | While tracking. status is the order status. | |
explorerUrl | string | null | Destination-chain explorer link: the fill transaction when known, otherwise the recipient. |
error | string | null | With error, written for the end user. |
queueIndex, queueTotal | number | Sentences with several intents run one at a time. |
isConnected | boolean | A wallet is set. |
Wallets
The session needs an IntentWallet. For a viem WalletClient, use the adapter:
import { viemWallet } from '@recoilpay/intent-core';
session.setWallet(viemWallet(walletClient));
session.setWallet(null); // on disconnect
For anything else, implement the interface yourself. It's six members:
interface IntentWallet {
address: `0x${string}`;
getChainId(): Promise<number>;
switchChain(chainId: number): Promise<void>;
signTypedData(args: { domain; types; primaryType; message }): Promise<`0x${string}`>; // EIP-712
writeContract(req: { chainId; address; abi; functionName; args }): Promise<`0x${string}`>;
sendTransaction(req: { chainId; to; value }): Promise<`0x${string}`>;
}
writeContract is used for the one-time Permit2 approval and for same-chain token sends. sendTransaction is used for same-chain native sends. Everything else is a signature.
Solana
Two optional members connect a Solana wallet. solanaAddress is where swaps to Solana devnet deliver, and what pays for swaps from it:
session.setWallet({ ...viemWallet(walletClient), solanaAddress: phantom.publicKey?.toBase58() ?? null });
For a swap to Solana, the user still pays and signs with the EVM wallet; the Solana address only receives. Without one, the swap stops with "To receive on Solana, connect a Solana wallet or name a Solana address to send to". A send … to <address> on solana names its own recipient and doesn't need it. The preview's recipient shows the Solana address before the user signs.
For a swap from Solana, the Solana wallet pays, so it also needs sendSolanaInstructions: put the instructions in one transaction, have the Solana account sign and send it, and return its signature.
import { Connection, PublicKey, Transaction, TransactionInstruction } from '@solana/web3.js';
const connection = new Connection('https://api.devnet.solana.com');
session.setWallet({
...viemWallet(walletClient),
solanaAddress: phantom.publicKey.toBase58(),
sendSolanaInstructions: async (ixs) => {
const tx = new Transaction().add(
...ixs.map((ix) => new TransactionInstruction({
programId: new PublicKey(ix.programId),
keys: ix.keys.map((k) => ({ ...k, pubkey: new PublicKey(k.pubkey) })),
data: Buffer.from(ix.data),
})),
);
return wallet.sendTransaction(tx, connection); // a wallet-adapter wallet
},
});
The only instruction it is asked to sign is the escrow's open, which locks the user's input for this one order. Before asking, the session checks the quote's escrow order is exactly the request: payer, token, amount, destination chain, recipient, the registry's escrow program, and an order id that matches its terms. If anything differs it stops, and nothing is signed. The EVM wallet signs nothing for a swap from Solana. Swaps within Solana are refused.
Cash-outs and purchases on Solana devnet use the other two Solana members, signSolanaMessage and signSolanaTransaction. solanaStandardWallet(wallet, account) provides all of them from any Wallet Standard wallet, and sendSolanaInstructions too when you pass { rpcUrl } and the wallet can sign and send. See Fiat ramps → On Solana.
explorerLink(chainId, kind, id) builds Solana explorer links too, with ?cluster=devnet for devnet.
Changing the wallet mid-flow is safe. A flow waiting in needsWallet continues, and a quote built for a different account is fetched again for the new one.
Options
createIntentSession({
hfAccessToken, // your Hugging Face token for parsing; setHfAccessToken() to change it
hfModel, // Hugging Face model; defaults to the one the RecoilPay app uses
apiUrl: 'https://api.recoilpay.com', // default
wallet, // or setWallet() later
pollIntervalMs: 2500, // order-status polling
fetch, // custom fetch: SSR, proxies, tests
chainReader, // on-chain reads (Permit2 allowance); defaults to the RPCs from /api/v1/chains
partnerKey, // your partner key, if you have one — see below
});
Call session.destroy() when you're completely done with a session. It stops polling and drops listeners.
Earning a share of the fee
If you've agreed terms with RecoilPay you'll have a partner key. Pass it as partnerKey and every call carries it:
const session = createIntentSession({ wallet, partnerKey: 'pk_recoil_…' });
It does two things. It credits the trades your app brings, and it applies the fee you agreed on top of RecoilPay's own. Both are charged to the user, not to you and not to the solver, and the total shows on the quote as preview.protocolFee in the token being received — already subtracted from receiveAmount, so what your user sees under "you receive" is what they get.
const { preview } = session.getState();
preview?.protocolFee; // e.g. '0.60' — the whole fee, yours included
Check a key and see your rates with createApiClient({ partnerKey }).getMyPartner().
Two things worth knowing:
- It is not a credential. An absent, stale or disabled key means the trade is credited to nobody and your fee isn't applied — but it still goes through. Your users never fail to trade because of a billing problem.
- Payouts are monthly, in USDC. Your share is recorded per trade and paid to the address on your partner record; it isn't a transfer on each trade, which is what keeps an order's gas the same whether or not it came through a partner.
Cash-out and purchase sessions accept partnerKey too, and record which trades came through you. They do not yet apply a partner fee: the fiat escrow charges a single rate fixed when it's deployed, so there's nowhere to add a second per-request fee.
Cash-outs and purchases
When a sentence is a cash-out or a purchase, the intent session stops at phase: 'offramp' or phase: 'onramp', with the resolved request in state.offramp or state.onramp. Hand it to its own session, createOfframpSession() or createOnrampSession(), which runs the quote, the bank details or identity check, the escrow and the confirmation:
const offramp = createOfframpSession({ wallet });
await offramp.quote(session.getState().offramp!);
await offramp.confirm({ rail: 'ng.nip', bankCode: '058', account: '0123456789', name: 'Ada Obi' });
// …later, once the solver says it paid
await offramp.confirmReceived();
The phases, the identity check and the API underneath are in Fiat ramps.
Building blocks
Every step is exported on its own, for when you want your own orchestration. For example, a backend or an agent could use them like this:
import {
createApiClient, parseIntent, buildSupportedSet, validateIntent, resolveIntent,
buildQuoteRequest, signQuote, buildOrderRequest,
} from '@recoilpay/intent-core';
const api = createApiClient();
const parsed = await parseIntent('swap 1 USDC on op sepolia for USDC on base sepolia', { hfAccessToken });
if (!parsed.ok) throw new Error(parsed.message);
const supported = buildSupportedSet(await api.getSupportedAssets());
const issues = validateIntent(parsed.intents[0], supported); // [] when executable
const intent = resolveIntent(parsed.intents[0], supported, userAddress);
const { quotes } = await api.getQuotes(buildQuoteRequest(intent));
const signature = await signQuote(quotes[0], signTypedData); // scheme-prefixed, ready to submit
const order = await api.submitOrder(buildOrderRequest(quotes[0], signature));
These are the same calls described in Forward your first intent, with the ERC-7930 encoding (interopAddress), the Permit2 helpers (hasPermit2Allowance, buildPermit2ApproveRequest) and the signature prefixes handled for you.