TypeScript SDK for OutLayer Agent Custody — multi-chain wallets for AI agents with TEE-enforced policy, multisig approvals, and gasless cross-chain transfers via NEAR Intents.
Docs · Examples · API spec · Source
If you've used Steward.fi or similar AI-agent wallet infra, you know the shape: API key in, signed transactions out. OutLayer keeps that shape but moves the trust root:
- Keys live in a TEE (Intel Trust Domain Extensions on Phala Cloud). The infrastructure operator cannot extract them.
- Policy is enforced inside the TEE, before signing, against an encrypted policy stored on the NEAR blockchain.
- Sovereign exit is available: a customer can permissionlessly recover their wallet's master key even if OutLayer shuts down. (Requires opting into the
vaultflow — see the vault docs.) - Multi-chain wallets (NEAR, Ethereum, Solana, Bitcoin) with deterministic address derivation and gasless cross-chain transfers via NEAR Intents.
npm install @outlayer/sdkRequires Node 18+ (uses native fetch and crypto.randomUUID). Works in the browser, Bun, and Deno with no extra config.
import { OutlayerClient } from '@outlayer/sdk';
// 1. Register an anonymous wallet. The API key is shown ONCE — save it.
const { apiKey, walletId, handoffUrl } = await OutlayerClient.register();
console.log('API key:', apiKey);
console.log('Set up policy:', handoffUrl);
// 2. Use the wallet
const client = new OutlayerClient({ apiKey });
const addr = await client.getAddress('near');
console.log('NEAR address:', addr.address);
const balance = await client.getBalance({ chain: 'near' });
console.log('Balance:', balance.balance);
// 3. Withdraw across chains — gasless via NEAR Intents
const result = await client.withdraw({
chain: 'ethereum',
to: '0x742d35Cc6634C0532925a3b844Bc9e7595f8b4f5',
amount: '1000000', // 1 USDT (6 decimals)
token: 'nep141:usdt.tether-token.near',
});
if (result.status === 'pending_approval') {
console.log(`Awaiting ${result.required} approvals; ${result.approved} so far.`);
} else {
console.log('Submitted as request', result.request_id);
}That's the whole flow. The wallet has no policy yet, so withdraws are unrestricted; visit handoffUrl in a browser to set spending limits, allowlists, and multisig.
| Surface | What it does |
|---|---|
OutlayerClient.register() |
Create a new wallet, get an API key |
client.getAddress(chain, subPath?) |
Derive address for NEAR / EVM (incl. HyperEVM) / Solana; subPath = an EVM sub-key |
client.getBalance({...}) |
Read on-chain or intents.near balance |
client.listTokens() |
Catalog of swap-capable tokens |
client.call({...}) |
Sign and broadcast a NEAR contract call |
client.transfer({...}) |
Native chain transfer (currently NEAR-only at the chain layer) |
client.withdraw({...}) |
Gasless cross-chain withdraw via NEAR Intents |
client.withdrawDryRun({...}) |
Policy + balance check without execution |
client.swap({...}) |
Cross-chain swap via 1Click |
client.swapQuote({...}) |
Price preview without execution |
client.intentsDeposit({...}) |
Move an on-NEAR FT into intents.near |
client.createDepositIntent({...}) |
Cross-chain deposit: one-time 1Click address to fund from another chain |
client.getDepositStatus(id) |
Poll a cross-chain deposit intent |
client.confidential*({...}) |
Same operations against the private confidential shard — shield / unshield / withdraw / swap / transfer / cross-chain deposit / balance (see below) |
client.signMessage({...}) |
NEP-413 or raw message signing (NEAR identity) |
client.evmSignTypedData({...}) |
Sign EIP-712 v4 typed data with the EVM key — e.g. a Polymarket CLOB order |
client.evmSignMessage({...}) |
Sign an EIP-191 personal_sign message with the EVM key |
client.evmSignTransaction({...}) |
Sign a raw unsigned EVM tx (you assemble + broadcast; gated by evm_sign.raw_tx) |
client.claimTrialKey() |
The wallet's trial: a nonce-0 payment key for connector calls in its first week |
client.redeemSponsorCode(code) |
Redeem a sponsor code (spn_…): a subscription on the nonce-0 key, paid by the sponsor |
client.getPaymentKey() |
The nonce-0 key derived again — send it as X-Payment-Key; nothing to store |
client.getRequest(id) |
Status of an async operation |
client.listRequests({...}) |
List recent operations |
client.policy.* |
Policy lifecycle (encrypt → sign → store) |
client.approvals.* |
Multisig approval workflow |
client.audit.list({...}) |
Event history |
Full reference: API spec.
Mirror of the wallet's NEAR Intents operations against the Defuse confidential
shard (intents.far on a private NEAR shard with no public RPC). Same SDK
shape, same wk_ API key, no extra signing on your side — just a different
balance shard. Every action is async: it returns a request_id you poll with
client.getRequest(id) until the status is terminal
(success / failed / refunded).
Routes return 503 confidential_unavailable unless the deployment has
confidential intents enabled — treat that as "not offered here", not a retryable
error (it surfaces as an OutlayerError with code === 'confidential_unavailable').
// SHIELD — move a public intents balance into the confidential shard.
// Links your wallet on chain (a convenience hop, NOT a private operation).
const shield = await client.confidentialDeposit({
token: 'nep141:wrap.near',
amount: '10000000000000000000000', // 0.01 wNEAR
});
// Async: poll client.getRequest(shield.request_id) until status is success / failed / refunded.
// Cross-chain DEPOSIT — fund the confidential balance from Solana USDC.
// The private path: your NEAR wallet never touches the public side.
const intent = await client.confidentialDepositIntent({
source_asset: 'nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near',
amount: '500000', // 0.5 USDC (6 decimals)
});
console.log('Send 0.5 USDC on Solana to:', intent.deposit_address);
// then poll client.getRequest(intent.intent_id) until it settles.
// NB: deposit-intent's poll key is `intent_id` (the action methods return `request_id`).
// Withdraw a confidential balance to a NEAR account as native NEAR
// (chain="near" runs a native_withdraw via 1Click).
await client.confidentialWithdraw({
chain: 'near',
to: 'zavodil.near',
amount: '10000000000000000000000',
token: 'nep141:wrap.near',
});
// Read your confidential balance(s). Pass a token for one; omit for the full list.
const one = await client.confidentialBalance({ token: 'nep141:wrap.near' });
if (!('balances' in one)) console.log('wNEAR (confidential):', one.balance);
const all = await client.confidentialBalance();
if ('balances' in all) {
for (const b of all.balances) console.log(b.token, b.balance);
}Privacy, in one line: SHIELD / UNSHIELD publicly link your wallet to a "moved into the shielded pool" event; cross-chain DEPOSIT / WITHDRAW keep your NEAR wallet invisible on chain. It is a shielded pool, not a mixer — the shard operator, auditors, and law enforcement with a warrant can still read confidential state. See the full agent integration guide for the mental model, threat model, and privacy recipes.
| Topic | Read this if you want to… |
|---|---|
| Getting started | Register, set a policy, do your first withdraw |
| Wallet operations | Send / receive / swap / withdraw — full method reference |
| Policy management | Configure spending limits, allowlists, time windows, multisig thresholds |
| Multisig approvals | Wire up the NEP-413 approval flow |
| Error handling | Handle PolicyDeniedError, WalletFrozenError, retries |
| Vaults (sovereign custody) | Bind a wallet to a deployed customer vault |
| Migration from raw HTTP | Move from fetch('/wallet/v1/...') to the SDK |
For production deployments that need sovereign exit guarantees, bind a wallet to a customer-owned vault. Vault deployment happens via the dashboard or outlayer vault init CLI (not the SDK — your NEAR keys never touch us). Once deployed, binding is one option:
const wallet = await OutlayerClient.register({ vaultId: 'vault.alice.near' });See docs/vaults.md and https://app.outlayer.ai/docs/vaults.
Runnable scripts in examples/:
01-register.ts— register a wallet, derive addresses on 4 chains, check balance02-withdraw.ts— gasless cross-chain withdraw with dry-run + polling03-multisig.ts— submit a withdraw that triggers the approval flow04-agent-loop.ts— minimal autonomous agent that respects policy05-cross-chain-app.ts— end-to-end DeFi flow: cross-chain login pattern, deposit instructions, swap USDT → NEAR, stake with a validator, gasless withdraw back to Ethereum. CLI with sub-commands (addresses | balances | buy-near | stake | unstake | withdraw-eth | login-demo).06-confidential-roundtrip.ts— confidential shard round-trip: SHIELD → read balance → UNSHIELD, with503 confidential_unavailablehandling.07-inbox.ts— the owner's inbox from a server: sign a device in with the custody wallet, read the tasks the owner's agents left, close the notices with Got it.
Run with:
npx tsx examples/01-register.ts # no auth needed
OUTLAYER_API_KEY=wk_... npx tsx examples/02-withdraw.ts # needs API key
OUTLAYER_API_KEY=wk_... npx tsx examples/05-cross-chain-app.ts addresses # cross-chain identityinbox reads the tasks an agent left for its owner — confirmations, inputs and
notices — on a device of the owner's, and closes the notices with Got it. Every
task is encrypted to the owner's devices; a device is a P-256 key pair the
owner's wallet signs in with one NEP-413 statement. The module holds no wallet
key: each signature is asked of a Signer, which for an OutLayer custody
wallet is its sign-message (inbox.walletSigner(client)). It does not
approve tasks: a task that asks something is approved where it is shown first.
import { OutlayerClient, inbox } from '@outlayer/sdk';
const signer = inbox.walletSigner(new OutlayerClient({ apiKey, network: 'testnet' }));
const device = await inbox.newDevice(); // keep inbox.exportDevice(device) server-side
const session = await inbox.signIn(
{ baseUrl: 'https://testnet-api.outlayer.ai', recipient: 'outlayer.testnet' },
signer, accountId, device, // a day, by default; 30 days at most
);
for (const listed of (await inbox.listTasks(session)).tasks) {
const task = await inbox.readTask(device, listed, accountId);
if (task.envelope.kind === 'notice') await inbox.acknowledge(session, listed.id);
}To be told of new tasks at a URL instead of asking: inbox.nameWebhook(session, url, signer) answers the secret, once; check each delivery with
inbox.verifyWebhook(rawBody, request.headers.get('x-webhook-signature'), secret). One URL per owner — naming one replaces any other. A delivery says who
asked whom, of what kind and when, and nothing of what the task shows: read the
task to show it. A custody account never sent NEAR signs in too; a wallet whose
policy lists the recipients of sign_message must list the OutLayer contract.
Every non-2xx response is thrown as a typed subclass of OutlayerError:
import { OutlayerClient, PolicyDeniedError, WalletFrozenError } from '@outlayer/sdk';
try {
await client.withdraw({ chain: 'near', to: 'bob.near', amount: '100' });
} catch (err) {
if (err instanceof PolicyDeniedError) {
console.log('Policy rejected:', err.message);
} else if (err instanceof WalletFrozenError) {
console.log('Wallet is frozen by the controller');
} else {
throw err;
}
}See errors.md for the full list.
Transient 5xx and network errors are retried automatically (3 attempts, exponential backoff 100ms → 1.6s). 4xx is not retried — those are deterministic.
Write operations get an auto-generated X-Idempotency-Key per call; retries from the SDK's own retry layer reuse the same key, so repeated calls don't double-spend. A key the server has seen answers duplicate_idempotency_key, thrown as a DuplicateRequestError carrying the request it belongs to (requestId, requestStatus, pollUrl; for a payment-check create, checks with each check_key, null when asked with another API key than the one that created it). This is what the SDK's own retry throws when the first attempt did run and the retry meets its key: catch it, take requestId, and poll pollUrl until the status is terminal — nothing ran twice. Pass answerWithinSeconds to be answered processing within that many seconds of the request's arrival instead of waiting the server's full wait (up to 80 s). To control idempotency yourself (e.g., for at-least-once delivery from a queue):
await client.withdraw({
chain: 'near',
to: 'bob.near',
amount: '1000000000000000000000000',
idempotencyKey: 'my-job-id-12345',
});Reusing the same key does not re-execute, and does not return the stored result either: the call resolves with { error: 'duplicate_idempotency_key', message: 'Request already processed: <request_id>' } — a pointer. Read the outcome with getRequest(request_id).
const client = new OutlayerClient({
apiKey: process.env.OUTLAYER_API_KEY!,
network: 'mainnet', // default; or 'testnet'
baseUrl: 'https://api.outlayer.ai', // optional, overrides network
fetch: customFetch, // optional, for SSR/proxies
retry: {
maxAttempts: 5,
initialDelayMs: 200,
maxDelayMs: 4000,
},
});network: 'testnet' targets https://testnet-api.outlayer.ai. Useful for development without spending real funds.
Important: NEAR Intents (cross-chain swaps and gasless withdrawals) only work on mainnet. On testnet you can still:
- register a wallet, derive addresses
- read balances, set policy, sign messages
- submit NEAR contract calls
- sign EVM payloads (
evmSignTypedData/evmSignMessage/evmSignTransaction) — pure off-chain secp256k1 signing, no intents/mainnet dependency
…but swap, intentsWithdraw, and intentsDeposit will fail at the intents layer.
Don't ship your API key to a browser. The key has full wallet authority. Either:
- Proxy through your backend — frontend calls your backend, your backend calls OutLayer.
- Use a payment key for that single tx — created via the dashboard, scoped to a specific operation.
The SDK works in browsers, but only with keys you've already gated.
This is 0.1.0-alpha — expect breaking changes until 0.x stabilizes around v1. Breaking changes will be documented in CHANGELOG.md. Pin to a patch version (0.1.0-alpha.1) in production until v1.
The OpenAPI spec is the source of truth — when adding endpoints, update out-layer/api-spec first, regenerate types here with npm run gen, then add ergonomic wrappers in src/client.ts.
git clone https://github.com/out-layer/sdk-js
cd sdk-js
npm install
npm run gen # regenerate src/types.ts from spec
npm run typecheck
npm test
npm run buildMIT.