# Functor
> Non-custodial agentic wallets with on-chain session-key delegation.
## Acknowledgments
Functor Keystore sits on top of wallet infrastructure: smart accounts, session keys, relayers, validator hooks.
For the EVM ecosystem, [**Porto Wallet**](https://porto.sh) is the best implementation of wallet infrastructure we've tested, and the Functor SDK is built on it. Thanks to the Porto team for the work that made this possible.
## Claude Skill
Functor ships a [Claude Code skill](https://docs.anthropic.com/en/docs/claude-code/skills) so any Claude-powered agent can build with `@functornetwork/agentic-wallet` correctly out of the box. The skill teaches Claude when to reach for the SDK, the six core functions, the common workflows, and the gotchas that bite at integration time.
### Install
Save the skill into your project (or your user-level skills directory) at `.claude/skills/functor-agentic-wallet/SKILL.md`:
```bash
mkdir -p .claude/skills/functor-agentic-wallet
curl -fsSL https://docs.functor.sh/skill.md \
-o .claude/skills/functor-agentic-wallet/SKILL.md
```
Restart Claude Code (or open a new conversation) and Claude will load the skill automatically when a prompt matches its triggers — agent wallets, scoped permissions, session keys, passkey wallets, cross-agent authorization checks, or "an AI that can act on my wallet."
The full skill content is reproduced below for reference. The canonical source is [`packages/wallet/SKILL.md`](https://docs.functor.sh/skill.md) in the SDK repo.
***
### When to reach for this skill
Recognize these patterns:
* "I want an AI agent that can trade / pay / mint on my behalf"
* "Grant this bot a $50/day spending limit on USDC"
* "How do two agents verify each other on-chain"
* "Build a wallet that recovers from a passkey"
* "Non-custodial wallet for my app, but I don't want users to handle seed phrases"
* "Revoke this key — make sure it can't sign anymore"
* "Check whether `
` is allowed to act on `` right now"
If the user is operating an existing wallet from Claude itself (one-off transactions, granting sessions to other agents), prefer the [**MCP server**](/mcp) — it exposes the SDK as tools/slash commands. If the user is **writing code** that uses Functor, use this SDK directly.
### The SDK in 6 functions
```ts
import {
createWallet, // smart account from a local private key (CLI, script, agent)
createPasskeyWallet, // smart account from a passkey (Face ID / Touch ID), browser
execute, // run calls as wallet admin OR as a session
grantSession, // admin authorizes a scoped session key on-chain
revokeSession, // admin pulls authority; effect is immediate
recoverFromPasskey, // browser: rebuild wallet handle from any saved passkey
} from "@functornetwork/agentic-wallet";
```
The private key lives wherever your code runs — your laptop, your agent's process, an OS keychain. **Functor never sees it.** Custody is local to the integrator.
Every wallet operation goes through one of these. `execute` is overloaded — admin path takes `(wallet, signer, calls)`; session path takes `(session, calls)`.
### Workflows
#### Local wallet from a private key
```ts
import { createWallet, signerFromPrivateKey, execute } from "@functornetwork/agentic-wallet";
// Key is read from wherever your code keeps it — env var, OS keychain,
// encrypted file. It never leaves the process.
const signer = signerFromPrivateKey(process.env.AGENT_PRIVATE_KEY as `0x${string}`);
const wallet = await createWallet({ signer });
// Send 0.001 ETH. First execute also registers the admin key in Keystore —
// happens transparently inside the same userOp.
const result = await execute(wallet, signer, {
to: "0xRecipient...",
value: 1_000_000_000_000_000n, // 0.001 ETH in wei
});
console.log(result.status, result.transactionHash);
```
#### Browser wallet with passkey
```ts
import { createPasskeyWallet, execute } from "@functornetwork/agentic-wallet";
const wallet = await createPasskeyWallet({
rpId: "myapp.example",
user: { name: "alice@example.com", displayName: "Alice" },
});
// `wallet.signer` is the PasskeySigner — used the same way as any other signer.
```
#### Grant a session to an agent
```ts
import { grantSession } from "@functornetwork/agentic-wallet";
const session = await grantSession(wallet, adminSigner, {
permissions: {
calls: [{ to: "0xUniswapRouter..." }], // only this contract
spend: [{
limit: 100_000_000n, // 100 USDC (6 decimals)
period: "day",
token: "0xUSDC...",
}],
},
expiry: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
});
// Hand `session` to whichever process runs the agent. Persist these fields:
// walletAddress, publicKey, permissions, expiry, and the signer's private key
// (signer.export() if your signer is a private-key signer). The agent needs
// the exact permissions+expiry at execute time — the on-chain validator
// matches them byte-for-byte against the authorization committed at grant.
```
#### Agent acts using a session
```ts
import { execute } from "@functornetwork/agentic-wallet";
const result = await execute(session, [
{ to: "0xUniswapRouter...", data: "0x...", value: 0n },
]);
```
#### Verify any key on-chain (from any tool)
```ts
import { createPublicClient, http, keccak256 } from "viem";
import { sepolia } from "viem/chains";
import { SEPOLIA } from "@functornetwork/agentic-wallet";
const client = createPublicClient({ chain: sepolia, transport: http() });
const KEYSTORE_ABI = [{
name: "getActiveKeys", type: "function", stateMutability: "view",
inputs: [{ name: "user", type: "address" }],
outputs: [{ type: "bytes32[]" }],
}] as const;
const active = await client.readContract({
address: SEPOLIA.keyStore,
abi: KEYSTORE_ABI,
functionName: "getActiveKeys",
args: [walletAddress],
});
const authorized = active.includes(keccak256(sessionPublicKey));
```
This is the killer feature: a wallet that has never heard of your app can still verify whether a given key is authorized. No vendor lock-in.
#### Revoke a session
```ts
await revokeSession(wallet, adminSigner, session);
// or, if you only kept the public key:
await revokeSession(wallet, adminSigner, sessionPublicKey);
```
Revocation revokes the key in [Keystore](/concepts/keystore) **and** pulls the session's on-chain authority in the same userOp. The session's next signed call reverts at validation. Revocation is monotonic in Keystore v1.0.0 — to restore access, grant a fresh session.
#### Recover a passkey wallet
```ts
import { recoverFromPasskey } from "@functornetwork/agentic-wallet";
const wallet = await recoverFromPasskey({ rpId: "myapp.example" });
// Browser shows the passkey picker; user picks one, biometric prompt, done.
// Two on-chain reads, no server, no localStorage required.
```
### When to use the MCP server vs the SDK directly
| You are… | Use |
| ------------------------------------------------ | ------------------------------------------------------------------------- |
| Writing TypeScript/JS code that needs wallet ops | `@functornetwork/agentic-wallet` (this SDK) |
| Operating wallets interactively from Claude Code | `@functornetwork/mcp` server, tools like `create_wallet`, `grant_session` |
| Building a UI that signs from the browser | `@functornetwork/agentic-wallet` with `createPasskeyWallet` |
| Running a local agent that holds its own session | `@functornetwork/agentic-wallet` with `execute(session, calls)` |
The MCP server is a thin wrapper around this SDK — anything the MCP does, you can do directly with the SDK.
### Notes
* **Funding.** `createWallet` faucets enough testnet ETH on Sepolia to activate the wallet. On mainnet, fund the address yourself before the first `execute`. Pass `skipFaucet: true` to skip the faucet entirely.
* **First execute registers the admin.** The Keystore `initialRegisterKey` is auto-prepended on the wallet's first admin-signed action. Don't pre-call it. The wallet is "live" but not on-chain until that first tx.
* **Sessions must be byte-exact on execute.** The on-chain validator matches `permissions + expiry + role + publicKey` exactly to the hash committed at grant time. Re-serializing through a sloppy JSON path (bigints → number, period reordering) breaks the match. Persist the `Session` object verbatim or reconstruct it identically.
* **Empty calls means no calls.** `execute(wallet, signer, [])` is rejected. Pass at least one call.
* **`permissions.calls` omitted = unrestricted.** If you don't pass `calls`, the session can call any contract within its spend cap. Set both unless that's truly what you want.
* **Sepolia only in v0.** `SEPOLIA` is the only network export. The architecture is chain-agnostic; mainnet addresses aren't deployed yet.
### Network
```ts
import { SEPOLIA } from "@functornetwork/agentic-wallet";
// SEPOLIA.keyStore = 0xfBDe00E03Bf281bAc666043B14dBb8FAbcf22b14 (v1.0.0)
// SEPOLIA.keyStoreController = 0x8bBabE825EcFCBB32f7B60D973FbA0B923b8e782 (v1.0.0)
// SEPOLIA.portoRelayUrl = https://rpc.porto.sh
```
### What never changes
* Functor never sees the private key. Custody follows the signer the integrator brings.
* Every authorization is on-chain in Keystore — readable by any tool, on any chain that bridges to it.
* Revoke is one tx, immediate.
## createPasskeyWallet
Creates a smart-account wallet whose admin authority is a passkey (Face ID, Touch ID, Windows Hello, hardware security key). The private key never leaves the device's secure hardware.
**Browser only.** Uses `navigator.credentials`.
```ts
import { createPasskeyWallet } from "@functornetwork/agentic-wallet";
const wallet = await createPasskeyWallet({
rpId: "myapp.example",
user: {
name: "alice@example.com",
displayName: "Alice",
},
});
// wallet.signer is a PasskeySigner. Use it like any other signer.
```
Same as [`createWallet`](/sdk/create-wallet): the wallet is counterfactual until the first [`execute`](/sdk/execute), which is when the admin (P-256) public key gets registered in [Keystore](/concepts/keystore).
### Parameters
```ts
type CreatePasskeyWalletOptions = {
/** Relying-Party ID. Usually your domain. Stored on the credential. */
rpId?: string;
/** Display info shown in the OS prompt. */
user: {
name: string;
displayName: string;
};
/** Override the default network (Sepolia). */
network?: NetworkConfig;
skipFaucet?: boolean;
};
```
### Returns
A standard `CreateWalletResult` whose `signer` is a `PasskeySigner`.
### Notes
* The wallet address is **embedded in the passkey's userHandle** at creation time. This is what makes [`recoverFromPasskey`](/sdk/recover-from-passkey) work later: the device knows which wallet each saved passkey belongs to.
* Authorization on the chain side uses **P-256 (secp256r1)** signatures, matching WebAuthn. [Keystore](/concepts/keystore) is signature-scheme agnostic, so it stores the P-256 public key the same way it stores secp256k1 keys.
* The user sees a single biometric prompt per signature. No seed phrases, no extension required.
### Example: full passkey app flow
```ts
import {
createPasskeyWallet,
recoverFromPasskey,
execute,
} from "@functornetwork/agentic-wallet";
async function loginOrSignup() {
try {
// Returning user: pick from saved passkeys
return await recoverFromPasskey({ rpId: "myapp.example" });
} catch {
// New user: create one
return await createPasskeyWallet({
rpId: "myapp.example",
user: { name: "alice@example.com", displayName: "Alice" },
});
}
}
const wallet = await loginOrSignup();
await execute(wallet, wallet.signer, { to: "0x...", value: 0n });
```
## createWallet
Creates a smart-account wallet for a signer. The signer's key lives wherever you keep it (env var, OS keychain, hardware wallet). Functor never sees it.
```ts twoslash
import { createWallet, signerFromPrivateKey } from "@functornetwork/agentic-wallet";
const signer = signerFromPrivateKey("0x...");
const wallet = await createWallet({ signer });
```
The wallet is **counterfactual**. Its address is deterministic, but it isn't a smart account on-chain until the first [`execute`](/sdk/execute). That first execute is what registers the admin key in [Keystore](/concepts/keystore).
### Parameters
```ts
type CreateWalletOptions = {
/** Bring your own signer. If omitted, the SDK generates a fresh private-key signer. */
signer?: Signer;
/** Override the default network (Sepolia). */
network?: NetworkConfig;
/** Skip the testnet faucet step. Use this on mainnet or when funding yourself. */
skipFaucet?: boolean;
};
```
### Returns
```ts
type CreateWalletResult = {
address: Address; // the wallet's smart-account address
network: { chainId: number };
signer: Signer; // same reference if you passed one in
};
```
### Notes
* On Sepolia with `skipFaucet: false` (default), the SDK faucets enough native ETH to cover the first transaction. On mainnet, fund `result.address` yourself before calling `execute`.
* If you omit `signer`, the SDK generates a fresh private-key signer and returns it on the result. Persist `result.signer` however your app stores keys.
### Example: generate a fresh wallet
```ts twoslash
import { createWallet } from "@functornetwork/agentic-wallet";
const wallet = await createWallet();
console.log("Address:", wallet.address);
console.log("Save this:", wallet.signer);
```
## execute
Submit one or more calls from a wallet. `execute` is overloaded: admin path takes `(wallet, signer, calls)`, session path takes `(session, calls)`.
### Keystore impact
| Scenario | Touches Keystore? |
| --------------------------------------- | ----------------------------------------- |
| First admin `execute` on a fresh wallet | Yes (admin key registered) |
| Subsequent admin `execute` calls | No |
| Any session `execute` | No (session was registered at grant time) |
### Admin path
The wallet's admin signs the intent. Use this for first-party operations.
```ts twoslash
import { execute, signerFromPrivateKey, createWallet } from "@functornetwork/agentic-wallet";
const signer = signerFromPrivateKey("0x...");
const wallet = await createWallet({ signer });
// ---cut---
const result = await execute(wallet, signer, {
to: "0xRecipient...",
value: 1_000_000_000_000_000n, // 0.001 ETH
});
console.log(result.status, result.transactionHash);
```
### Session path
A session signs the intent. Use this for agent-driven operations.
```ts twoslash
import { execute, grantSession, createWallet, signerFromPrivateKey } from "@functornetwork/agentic-wallet";
const admin = signerFromPrivateKey("0x...");
const wallet = await createWallet({ signer: admin });
const session = await grantSession(wallet, admin, {
permissions: { calls: [{ to: "0x..." as const }] },
expiry: Math.floor(Date.now() / 1000) + 3600,
});
// ---cut---
const result = await execute(session, [
{ to: "0xUniswapRouter...", data: "0x...", value: 0n },
]);
```
### Parameters
```ts
function execute(
wallet: Wallet,
signer: Signer,
calls: Call | readonly Call[],
opts?: ExecuteOptions,
): Promise;
function execute(
session: Session,
calls: Call | readonly Call[],
opts?: ExecuteOptions,
): Promise;
type Call = {
to: Address;
data?: Hex;
value?: bigint;
};
type ExecuteOptions = {
network?: NetworkConfig;
feeToken?: Address; // default: native (ETH)
noWait?: boolean; // return as soon as submitted, don't wait for confirmation
};
```
### Returns
```ts
type ExecuteResult = {
callsId: Hex;
status: "CONFIRMED" | "FAILED" | "PENDING";
transactionHash?: Hex;
};
```
### Notes
* **First execute on a fresh wallet auto-registers the admin key** in [Keystore](/concepts/keystore). `initialRegisterKey` is prepended transparently in the same userOp.
* **Empty calls is rejected.** `execute(wallet, signer, [])` errors. Pass at least one call.
* **Session execute is byte-exact.** The on-chain validator matches the session's `permissions + expiry + role + publicKey` to the hash committed at grant. Persist the `Session` object verbatim.
* With `noWait: true`, you get `status: "PENDING"` and a `callsId` to poll later.
## grantSession
Grant a scoped session key for a wallet. The admin signer authorizes the session on-chain; from that point forward the session can act on the wallet within its permissions, enforced on-chain.
```ts twoslash
import { grantSession, createWallet, signerFromPrivateKey } from "@functornetwork/agentic-wallet";
const admin = signerFromPrivateKey("0x...");
const wallet = await createWallet({ signer: admin });
// ---cut---
const session = await grantSession(wallet, admin, {
permissions: {
calls: [{ to: "0xUniswapRouter..." }],
spend: [{
limit: 100_000_000n, // 100 USDC (6 decimals)
period: "day",
token: "0xUSDC...",
}],
},
expiry: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
});
// Hand `session` to whichever process runs the agent.
```
### Keystore impact
The session's public key is written to [Keystore](/concepts/keystore) via the Controller. From that moment, any tool reading `getActiveKeys` on the wallet sees this session. Reads are free and unlimited.
### Parameters
```ts
function grantSession(
wallet: Wallet,
adminSigner: Signer,
opts: GrantSessionOptions,
config?: { network?: NetworkConfig; feeToken?: Address },
): Promise;
type GrantSessionOptions = {
permissions: SessionPermissions;
/** Unix epoch seconds. Most apps use Date.now()/1000 + N. */
expiry: number;
/** Bring your own session signer, or omit to let the SDK generate one. */
sessionSigner?: Signer;
};
```
See [Sessions](/concepts/sessions) for the full permission shape reference.
### Returns
```ts
type Session = {
walletAddress: Address;
signer: Signer; // session key. Agent signs with this.
publicKey: Hex;
permissions: SessionPermissions;
expiry: number;
};
```
### What lands on-chain
In a single userOp:
1. The session's public key is registered in [Keystore](/concepts/keystore), making it discoverable by any tool.
2. The session is authorized on the wallet's smart account with its permissions hash.
Both happen atomically. There is no intermediate state where one exists without the other.
### Notes
* **Persist the full `Session` object**, not just `publicKey`. The agent needs `permissions + expiry` byte-exact at execute time.
* If you generated the session signer (omitted `sessionSigner`), persist `session.signer` securely. Losing it means the session can no longer sign.
* **`permissions.calls` omitted = unrestricted.** Always set both `calls` and `spend` unless you specifically want an open-scope session.
## recoverFromPasskey
Recover a passkey-backed wallet using on-chain state and the OS keychain. **Browser only.**
```ts
import { recoverFromPasskey } from "@functornetwork/agentic-wallet";
const wallet = await recoverFromPasskey({ rpId: "myapp.example" });
// Browser shows the passkey picker, biometric prompt, done.
// Two on-chain reads, no server, no localStorage required.
```
### Keystore impact
Recovery is a pure read: two `eth_call`s against Keystore (`getActiveKeys` + `getPublicKey`). No on-chain write, no transaction. An app can recover a user's wallet handle a million times a day at zero cost.
### Flow
1. **Discoverable-credential picker.** `allowCredentials: []` tells the OS to show all passkeys saved on this device for the given `rpId`. The user picks one, then completes biometric verification.
2. **Extract the wallet address.** The assertion's `userHandle` carries the 20-byte wallet address that `createPasskeyWallet` baked in at creation time.
3. **Read Keystore.** `getActiveKeys(wallet) + getPublicKey(wallet, keyId)` to retrieve the P-256 public key the wallet authorized.
4. **Rebuild the signer.** From `{ credentialId, publicKey, rpId }`.
Two `eth_call`s, one biometric prompt. No server side-channel.
### Parameters
```ts
type RecoverFromPasskeyOptions = {
/** Relying-Party ID. Must match what was used at creation time. */
rpId?: string;
/** Override the default network (Sepolia). */
network?: NetworkConfig;
};
```
### Returns
A `CreateWalletResult` whose `signer` is a `PasskeySigner`. Drop-in compatible with `execute`, `grantSession`, etc.
### Notes
* **The wallet must have transacted at least once.** Recovery reads the admin key from Keystore, which is populated on the wallet's first `execute`. A wallet that was created but never used isn't yet on-chain to recover from.
* **The `rpId` must match.** Passkeys are scoped to a relying-party ID, usually a domain. If you used `"myapp.example"` at creation, you must use the same here.
* **No fallback to localStorage.** Recovery is purely on-chain plus device-resident. This is what makes Functor wallets durable across machines, browser data wipes, and OS migrations, as long as the passkey is in iCloud Keychain or Google Password Manager.
## revokeSession
Revoke a session key from a wallet on-chain. After confirmation, the session's next execute attempt reverts at validation. Effect is global and immediate. No off-chain coordination required.
```ts twoslash
import { revokeSession, grantSession, createWallet, signerFromPrivateKey } from "@functornetwork/agentic-wallet";
const admin = signerFromPrivateKey("0x...");
const wallet = await createWallet({ signer: admin });
const session = await grantSession(wallet, admin, {
permissions: {},
expiry: Math.floor(Date.now() / 1000) + 3600,
});
// ---cut---
await revokeSession(wallet, admin, session);
```
You can also pass just the session's public key:
```ts twoslash
import { revokeSession, createWallet, signerFromPrivateKey } from "@functornetwork/agentic-wallet";
const admin = signerFromPrivateKey("0x...");
const wallet = await createWallet({ signer: admin });
const sessionPublicKey = "0x04..." as `0x${string}`;
// ---cut---
await revokeSession(wallet, admin, sessionPublicKey);
```
### Keystore impact
Revocation calls Keystore directly, not the Controller. The call is gated on-chain by `onlyKeyOwnerOrValidator`, so only the wallet itself (executing inside its own userOp) or a designated validator can revoke a key. A random caller reverts at the modifier.
Revocation is **monotonic**: once a key is revoked, it cannot be reactivated. To restore session access for a wallet, grant a new session with a fresh keypair.
### Parameters
```ts
function revokeSession(
wallet: Wallet,
adminSigner: Signer,
sessionOrPublicKey: Session | Hex,
config?: { network?: NetworkConfig; feeToken?: Address },
): Promise;
```
### What lands on-chain
In a single userOp:
1. The session is **revoked in Keystore**. Any tool reading `getActiveKeys` will no longer see it.
2. The session's authority on the wallet's smart account is pulled.
Both atomic. After the userOp confirms, the session is dead everywhere simultaneously.
### Notes
* Revocation does not require the session signer, only the wallet's admin signer.
* You can keep just the session's public key in your records for revocation purposes, then discard the rest of the `Session` object once granted.
## ensureKeyCached
A session you granted via [`grantSession`](/sdk/grant-session) is registered in the [Keystore](/concepts/keystore) on L1 (Sepolia). For another chain to honor that session, the registry state has to be mirrored to an L2 cache on that chain — `ensureKeyCached` does that.
It's idempotent: if the L2 cache already has the key, it returns immediately. Otherwise it waits for the L2 to anchor past the relevant L1 block, then submits a storage proof.
```ts
import { ensureKeyCached, SEPOLIA, BASE_SEPOLIA } from "@functornetwork/agentic-wallet";
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia, baseSepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const l1Client = createPublicClient({ chain: sepolia, transport: http(SEPOLIA.publicRpcUrl) });
const l2Client = createPublicClient({ chain: baseSepolia, transport: http(BASE_SEPOLIA.publicRpcUrl) });
// The L2 wallet client pays L2 gas to submit the proof. This can be ANY
// account with L2 native ETH — your own EOA, a backend relayer, a per-user
// funded address, etc. The proof is permissionless; no privilege is granted.
const l2WalletClient = createWalletClient({
account: privateKeyToAccount(relayerKey),
chain: baseSepolia,
transport: http(BASE_SEPOLIA.publicRpcUrl),
});
await ensureKeyCached({
l1Client,
l2Client,
l2WalletClient,
l1KeyStore: SEPOLIA.keyStore,
l2Cache: BASE_SEPOLIA.keyStoreCache,
user: session.walletAddress,
publicKey: session.publicKey,
onStatus: (status) => {
// "cache-hit" | "waiting-for-anchor" | "submitting-proof" | "done"
console.log(status);
},
});
// Session is now valid on Base Sepolia. Subsequent agent actions are instant.
```
### What it does
1. Reads `isValidKey(user, keyId)` on the L2 cache. If `true`, returns immediately (`cache-hit`).
2. Otherwise polls the L2's `L1Block` predeploy until it points to an L1 block that contains the registration (`waiting-for-anchor`). Base Sepolia anchors \~1–3 min behind L1; mainnet OP Stack L2s have similar lag.
3. Fetches an `eth_getProof` against L1 for the packed Key storage slot, RLP-encodes the L1 block header, and submits `populateKey(...)` to the L2 cache (`submitting-proof`).
4. Reads the cache back and resolves with the populated `CachedKey` (`done`).
The first cross-chain action on a given chain pays this cost once. After the cache is populated, the L2 reads it locally without further L1 round-trips.
### Who pays L2 gas
The submitter of `populateKey` pays. The op is permissionless on the contract — anyone can land a valid proof — but *someone* has to send the L2 tx. Practically:
* **A relayer EOA you control.** Simplest in v0. Fund a single address on each L2 and have your backend submit proofs on demand.
* **The user themselves**, if they already hold L2 native ETH. Pass their `WalletClient`.
* **A future bundler-batched flow** that includes `populateKey` as the first call of the L2 userOp. Not wired into `execute` yet.
There's no on-protocol relayer. The cache contract is just a verifier.
### Parameters
```ts
function ensureKeyCached(args: EnsureKeyCachedArgs): Promise;
type EnsureKeyCachedArgs = {
/** Public client on the L1 chain (Sepolia). Used for eth_getProof + block lookup. */
l1Client: PublicClient;
/** Public client on the L2 chain. Used for L1Block read + receipt wait. */
l2Client: PublicClient;
/** Wallet client on the L2 chain — the relayer that pays L2 gas. */
l2WalletClient: WalletClient;
/** L1 Keystore address. From SEPOLIA.keyStore. */
l1KeyStore: Address;
/** L2 cache address. From BASE_SEPOLIA.keyStoreCache. */
l2Cache: Address;
/** Wallet whose key is being mirrored — the smart account that registered on L1. */
user: Address;
/** SEC1-encoded public key bytes for the session being mirrored. */
publicKey: Hex;
/** Progress callback for the four lifecycle states. */
onStatus?: (status: EnsureKeyCachedStatus) => void;
/** Poll cadence while waiting for L2 to anchor. Default 3s. */
anchorPollIntervalMs?: number;
/** Max wait for the L1 anchor. Default 5 minutes. */
anchorTimeoutMs?: number;
};
type EnsureKeyCachedStatus =
| "cache-hit"
| "waiting-for-anchor"
| "submitting-proof"
| "done";
```
### Returns
```ts
type CachedKey = {
publicKey: Hex;
revoked: boolean;
expiry: number; // unix seconds, 0 = never expires
isRoot: boolean;
sourceBlockHash: Hex;
sourceBlockNumber: bigint;
};
```
The `sourceBlockHash` / `sourceBlockNumber` fields record which L1 block the cache state was proven against — useful for monitoring how stale the L2 view is.
### Related helpers
`ensureKeyCached` is the function most apps want. The lower-level helpers are also exported for cases where you want explicit control:
```ts
// Always submit a fresh proof regardless of cache state. Use to propagate a
// revocation you just landed on L1 without waiting for the next cache miss.
syncKeyToL2(args): Promise<{ txHash: Hex; cachedKey: CachedKey }>;
// Pure read against the L2 cache. Returns zeroed struct if not yet populated.
readCachedKey(l2Client, l2Cache, user, keyId): Promise;
// Pure read: cache has the key AND it's not revoked AND not expired.
// Equivalent to KeyStoreCache.isValidKey(user, keyId).
isCachedKeyValid(l2Client, l2Cache, user, keyId): Promise;
```
### Notes
* **L1 Keystore is the source of truth.** The L2 cache is only as fresh as the last proof submitted. Revocations on L1 do **not** propagate automatically; until someone submits a post-revocation proof, the L2 cache still reports the key as valid. Call `syncKeyToL2` right after `revokeSession` to push the revocation through, or rely on `ensureKeyCached` running again on the next action.
* **Monotonic revoke is enforced cache-side too.** Once the cache observes `revoked=true`, it cannot be flipped back to live by a later proof — even one against an older L1 block. This prevents replay attacks during the L1→L2 anchor window.
* **The cache is permissionless.** Any address can submit a proof for any `(user, publicKey)` pair. There is no admin and no relayer registry.
* **One cache per chain.** For Base Sepolia, use `BASE_SEPOLIA.keyStoreCache`. Other L2s land here as they ship; see the [Chains](/concepts/keystore#chains) table.
## MCP Server
`@functornetwork/mcp` exposes the SDK as an MCP server. AI hosts like Claude Code, Cursor, and Continue can use it to create wallets, grant sessions, and execute transactions through tools or slash commands.
### When to use it
* You want to **operate** wallets from a chat interface ("create a wallet for me", "grant this bot a daily cap of 0.01 ETH").
* You're prototyping agent flows and want to test session lifecycles by hand.
* You're building demos where the user is an AI host, not your code.
If you're writing code, use [`@functornetwork/agentic-wallet`](/getting-started/passkey) directly. The MCP server is a thin wrapper.
### What it provides
* **11 tools** covering wallet creation, balance, verification, session lifecycle, and transactions.
* **12 prompts** exposed as slash commands (`/functor-agentic-wallet:create-wallet`, etc.).
* **Stateless key handling.** Keys live in your OS keychain, with file/env fallbacks.
### Next
* [Install](/mcp/install). Get the server running in your AI host.
* [Tools](/mcp/tools). Full reference for every exposed tool.
## Install
### Claude Code
```bash
claude mcp add functor -- bunx @functornetwork/mcp
```
That's it. Restart Claude Code; tools and slash commands become available.
To remove:
```bash
claude mcp remove functor
```
### Cursor / Continue / other hosts
Add this to your host's MCP server config:
```json
{
"mcpServers": {
"functor": {
"command": "bunx",
"args": ["@functornetwork/mcp"]
}
}
}
```
### Keys
Wallet admin keys and session keys live in **separate namespaces** so they can never collide. The server reads each kind from three places, in order.
**Wallet admin keys:**
1. **OS keychain** under service `functor-wallet`. Primary. Written by `create_wallet`.
2. **`~/.functor/keys.json`** → `wallets[]`. File fallback. Mode 0600.
3. **`FUNCTOR_WALLET__PRIVATE_KEY`** env var. Env fallback.
For the default wallet, the convention is `FUNCTOR_WALLET_DEFAULT_PRIVATE_KEY`.
**Session keys:**
1. **OS keychain** under service `functor-session`. Primary. Written by `grant_session`.
2. **`~/.functor/keys.json`** → `sessions[]`. File fallback.
3. **`FUNCTOR_SESSION__PRIVATE_KEY`** env var.
Functor never sees these keys. They stay on your machine.
## MCP Tools
The Functor MCP server exposes 11 tools. AI hosts call them by name; users can also invoke them as slash commands via the prompt interface (e.g. `/functor-agentic-wallet:create-wallet`).
### Discovery
| Tool | Purpose |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `about_functor` | Returns Functor's positioning, Keystore explainer, and SDK surface. AI hosts call this when the user asks "what is Functor". |
### Wallet lifecycle
| Tool | Purpose |
| ---------------- | ---------------------------------------------------------------------------------------------------- |
| `create_wallet` | Generates a new private key, stores it in the OS keychain under the given name, returns the address. |
| `list_wallets` | Lists wallet names available on this machine (across keychain, file, env). |
| `wallet_balance` | Reads native + ERC-20 balances for a stored wallet. |
| `wallet_execute` | Submits one or more calls signed by the wallet's admin key. |
### Verification
| Tool | Purpose |
| ---------------------- | ---------------------------------------------------------------------- |
| `wallet_verification` | Lists all active keys on a wallet from [Keystore](/concepts/keystore). |
| `verify_authorization` | Answers: is this key/session authorized on this wallet right now? |
### Session lifecycle
| Tool | Purpose |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `grant_session` | Generates a session key, registers it in Keystore, authorizes it with the given permissions. Returns the session details + keyId. |
| `list_sessions` | Lists local session metadata (those granted from this machine). |
| `session_execute` | Submits calls signed by a stored session key. |
| `revoke_session` | Deactivates the session in Keystore and pulls on-chain authority. |
### Slash command equivalents
Every tool has a matching prompt, exposed as a slash command:
| Slash command | Calls |
| ----------------------------------------- | ------------------------------- |
| `/functor-agentic-wallet:about` | `about_functor` |
| `/functor-agentic-wallet:create-wallet` | `create_wallet` |
| `/functor-agentic-wallet:list-wallets` | `list_wallets` |
| `/functor-agentic-wallet:wallet-balance` | `wallet_balance` |
| `/functor-agentic-wallet:wallet-info` | `wallet_verification` |
| `/functor-agentic-wallet:verify-session` | `verify_authorization` |
| `/functor-agentic-wallet:grant-session` | `grant_session` |
| `/functor-agentic-wallet:list-sessions` | `list_sessions` |
| `/functor-agentic-wallet:session-execute` | `session_execute` |
| `/functor-agentic-wallet:revoke-session` | `revoke_session` |
| `/functor-agentic-wallet:send-tx` | `wallet_execute` |
| `/functor-agentic-wallet:demos` | listing of available demo flows |
## Getting Started · Passkey Wallet
The default path for end-user wallets. The wallet is authorized by a **passkey** (Face ID, Touch ID, Windows Hello, or a hardware security key). The private key never leaves the device's secure hardware.
This is what most consumer apps should build on.
### Install
```bash
npm install @functornetwork/agentic-wallet viem
```
### Create the wallet
```ts
import { createPasskeyWallet } from "@functornetwork/agentic-wallet";
const wallet = await createPasskeyWallet({
rpId: "myapp.example",
user: {
name: "alice@example.com",
displayName: "Alice",
},
});
console.log(wallet.address);
```
The user sees a single biometric prompt. After that, you have a fully usable smart-account wallet. `wallet.signer` is drop-in compatible with every other SDK function.
**Browser only.** Uses `navigator.credentials` (WebAuthn).
### Run a transaction
```ts
import { execute } from "@functornetwork/agentic-wallet";
const result = await execute(wallet, wallet.signer, {
to: "0xRecipient...",
value: 1_000_000_000_000_000n, // 0.001 ETH
});
console.log(result.status, result.transactionHash);
```
Each `execute` triggers one biometric prompt to sign. No popups, no extensions, no seed phrase.
### Returning users
When a user comes back to your app on the same device (or any device with their passkeys synced), recover their wallet without any state of your own:
```ts
import { recoverFromPasskey } from "@functornetwork/agentic-wallet";
const wallet = await recoverFromPasskey({ rpId: "myapp.example" });
// OS shows the passkey picker, biometric prompt, done.
```
Two on-chain reads, one biometric. No localStorage, no server side-channel, no seed phrase. The user's wallet handle is rebuilt from on-chain state.
### What's next
* [Grant a session to an agent](/sdk/grant-session). Let an AI act on this wallet with scoped permissions.
* [Keystore](/concepts/keystore), the public registry that makes recovery and cross-app verification possible.
* [Private-key wallets](/getting-started/private-key), the other wallet path, for agents and scripts.
## Getting Started · Private-Key Wallet
The path for agents, scripts, and backend services. Anywhere a passkey doesn't make sense.
The private key lives **wherever your code runs**: your laptop's OS keychain, an env var, an encrypted file, a hardware wallet. Functor never sees it. There is no Functor-side custody, no API key, no off-chain authorization service.
### Install
```bash
npm install @functornetwork/agentic-wallet viem
```
### Create the wallet
```ts
import { createWallet, signerFromPrivateKey } from "@functornetwork/agentic-wallet";
const signer = signerFromPrivateKey(process.env.PRIVATE_KEY as `0x${string}`);
const wallet = await createWallet({ signer });
console.log(wallet.address);
```
Or let the SDK generate a fresh key for you:
```ts
import { createWallet } from "@functornetwork/agentic-wallet";
const wallet = await createWallet();
// wallet.signer is a freshly generated PrivateKeySigner. Persist it however your app stores secrets.
```
On Sepolia, `createWallet` faucets enough testnet ETH to activate the wallet on its first transaction. On mainnet, fund `wallet.address` yourself before continuing, or pass `skipFaucet: true` to skip the faucet step explicitly.
### Run a transaction
```ts
import { execute } from "@functornetwork/agentic-wallet";
const result = await execute(wallet, signer, {
to: "0xRecipient...",
value: 1_000_000_000_000_000n, // 0.001 ETH
});
console.log(result.status, result.transactionHash);
```
The first `execute` on a fresh wallet does three things in one userOp:
1. Registers the admin key in [Keystore](/concepts/keystore).
2. Activates the smart account.
3. Submits your call.
After that, every `execute` is just your calls.
### What's next
* [Grant a session](/sdk/grant-session). Give a scoped, time-bounded key to an AI agent.
* [Passkey wallets](/getting-started/passkey), the other wallet path, for end-user apps in the browser.
* [Keystore](/concepts/keystore), the public registry of who is authorized to act on a wallet.
## How Functor is Different
There are plenty of "agentic wallet" products. Most of them solve the same surface problem (letting an AI act on a user's wallet) but they all store the authorization state in places only their own stack can read.
Functor inverts that. **Permissions are first-class on-chain objects.** Any app, any agent, any chain that bridges to it can verify who is authorized to act on a wallet, without integrating with a wallet vendor's proprietary API.
### What "agentic wallet" usually means
Most stacks combine:
* a smart-account contract (ERC-4337 or similar) that holds funds
* a session-key authorization mechanism (the agent signs intents, the wallet validates)
* a vendor-side coordinator (a backend or proprietary contract) that knows which keys are authorized
The third piece is where the silos live. Two agents on the same user's wallet can't verify each other unless they're both clients of the same vendor. A DEX can't check whether an incoming caller is authorized without integrating per-vendor.
### What Functor does
Functor replaces the vendor-side coordinator with **Keystore: a public on-chain registry**.
| | Most agentic wallets | Functor |
| ----------------------------------- | -------------------------------------------- | --------------------------------------- |
| Who knows which keys are authorized | The vendor's backend or proprietary contract | Anyone reading the on-chain registry |
| Cross-agent verification | Requires both agents on the same platform | One `eth_call` from any client |
| Cross-app integration | Per-vendor SDK | Read the registry |
| Revocation | A vendor API call | One on-chain transaction, global effect |
| Custody | Often vendor-side or platform-specific | Local. The integrator's signer. |
### Cost model
Keystore splits writes and reads.
* **Writes** register or revoke a key. Done by the wallet itself, batched into the userOp the user was already submitting.
* **Reads are free and unlimited.** Verifying whether a key is authorized is an `eth_call` against any RPC. An agent can check a million times per second at zero cost.
This is what makes cross-agent and cross-app verification practical at scale. The party doing the verification never pays.
### Concretely, this enables
* **Agent-to-agent verification.** Two AIs acting on the same wallet can verify each other's authority on-chain. No platform in between.
* **Cross-app authorization.** A DEX, lending protocol, or orderbook can check whether a key is allowed to act on a wallet, without integrating with any wallet vendor.
* **One-tx revocation.** Pulling a key from the registry is a single transaction. The effect is global and immediate; no off-chain coordination required.
* **A new class of agent services.** Users hire AI agents through on-chain employment contracts. Anyone can verify what an agent is allowed to do, and revoke takes one tx.
### What stays the same
* You still get a smart-account wallet with the standard session-key features (scoped permissions, time-bounded expiry, on-chain validation).
* You still use [`grantSession`](/sdk/grant-session), [`execute`](/sdk/execute), [`revokeSession`](/sdk/revoke-session). Same shape as any other session-key SDK.
* Funds still live in the wallet's smart account. Functor never touches them.
The difference is what surface knows about the authorizations, and that difference is what unlocks the cross-app, cross-agent properties.
### Read more
* [Keystore](/concepts/keystore). The public registry, with code for verifying authorizations.
* [Sessions](/concepts/sessions). Scoped delegations enforced on-chain.
## Keystore
**Keystore is a public on-chain registry.** For every Functor wallet, it stores which keys are currently authorized to act on it. Anyone (any app, any agent, any chain that bridges to it) can read this state and verify authority without a vendor in the middle.
### Why this matters
Traditional wallet vendors keep their authorization state inside proprietary contracts or backend services. Two agents acting on the same user's wallet can't verify each other unless they're both clients of the same vendor.
Keystore inverts this: authorization is the source of truth, on-chain, vendor-neutral.
This unlocks:
* **Cross-agent verification.** An agent can verify another agent's authority before delegating work to it.
* **Cross-app authorization.** A DEX, lending protocol, or orderbook can check whether a key is allowed to act on a wallet, without integrating with any wallet vendor.
* **One-tx revocation.** Pulling a key from Keystore is a single transaction. The effect is global and immediate.
### Writes vs reads
**Writes (on-chain transactions):**
* Registering a key (admin on first `execute`, or session on `grantSession`). Goes through the Controller.
* Revoking a key (`revokeSession`). Calls Keystore directly. **Gated by access control** (`onlyKeyOwnerOrValidator`); only the wallet itself or a designated validator can revoke, so there is no open write surface. Revocation is **monotonic** — a revoked key cannot be reactivated.
**Reads (eth\_call, free, unlimited):**
* Checking which keys are active on a wallet.
* Verifying whether a given key is currently authorized.
* Looking up a key's stored public bytes.
Reads are off-chain RPC calls. An agent can verify authorization a million times per second, from any RPC, without paying anything. This is what makes cross-agent and cross-app verification practical at scale.
### How a read works
```ts
import { createPublicClient, http, keccak256 } from "viem";
import { sepolia } from "viem/chains";
import { SEPOLIA } from "@functornetwork/agentic-wallet";
const client = createPublicClient({ chain: sepolia, transport: http() });
const KEYSTORE_ABI = [{
name: "getActiveKeys",
type: "function",
stateMutability: "view",
inputs: [{ name: "user", type: "address" }],
outputs: [{ type: "bytes32[]" }],
}] as const;
const active = await client.readContract({
address: SEPOLIA.keyStore,
abi: KEYSTORE_ABI,
functionName: "getActiveKeys",
args: [walletAddress],
});
const authorized = active.includes(keccak256(sessionPublicKey));
```
A single eth\_call answers: *is this key allowed to act on this wallet right now?*
### When the SDK writes to Keystore
| SDK call | Write? | Notes |
| --------------------------------- | ------ | --------------------------------------------------------------------------------------------------- |
| `createWallet` | No | Wallet is counterfactual until first `execute` |
| `createPasskeyWallet` | No | Same as above |
| First `execute` on a fresh wallet | Yes | Admin key auto-registered via `initialRegisterKey`, batched into your userOp |
| Subsequent `execute` calls | No | Just your calls |
| `grantSession` | Yes | Session public key registered, batched with on-chain authorization |
| `revokeSession` | Yes | Pulls the session's authority. Revocation is monotonic — once revoked, a key cannot be reactivated. |
| `recoverFromPasskey` | No | Pure read, two eth\_calls |
All writes are batched into the same userOp as your actual call. You never call Keystore directly through the SDK.
### Reaching other chains
The L1 Keystore is the source of truth. For another chain to honor an authorization, the registry state has to be mirrored to a **cache contract** on that chain.
A cache works as a verifier — it accepts a storage proof against the L1 Keystore, walks it from the L1 block hash exposed by the L2's `L1Block` predeploy down to the relevant slot, and stores the result locally. Once cached, the L2 reads authorization with a single `eth_call` — no L1 round-trip, no bridge message.
### Chains
| Network | Chain ID | Role |
| ---------------- | -------- | ----------------------------- |
| Ethereum Sepolia | 11155111 | L1 Keystore (source of truth) |
| Base Sepolia | 84532 | L2 cache |
Sepolia + Base Sepolia are live today. Additional cache deployments on other EVM chains land here as they ship.
## Sessions
A **session** is a scoped, time-bounded delegation from a wallet's admin key to another key. The session key can act on the wallet, but only within the granted permissions, and only until the expiry.
Permissions are enforced on-chain. A session that tries to call a contract outside its allowlist, or spend beyond its cap, reverts at validation time. There is no off-chain trust assumption.
### Anatomy of a session
```ts
type Session = {
walletAddress: Address; // the wallet this session can act on
signer: Signer; // the session key (agent signs with this)
publicKey: Hex; // identifier on-chain
permissions: SessionPermissions;
expiry: number; // unix epoch seconds
};
type SessionPermissions = {
calls?: readonly CallPermission[]; // allowed contracts / signatures
spend?: readonly SpendPermission[]; // per-token rolling caps
};
```
### Permission shapes
**Call permissions** restrict which contracts and methods the session can hit:
```ts
calls: [
{ to: "0xUniswapRouter..." }, // any method on this contract
{ signature: "transfer(address,uint256)" }, // any contract, this method
{ signature: "swap(...)", to: "0xPool" }, // both, AND semantics
]
```
**Spend permissions** cap how much the session can move per token, per rolling period:
```ts
spend: [
{ limit: 100_000_000n, period: "day", token: "0xUSDC..." }, // 100 USDC/day
{ limit: 10n ** 16n, period: "hour" }, // 0.01 ETH/hour (native)
]
```
### Lifecycle
| Stage | Function | Keystore impact |
| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Grant | [`grantSession`](/sdk/grant-session) | Write. Session public key registered. |
| Use | [`execute(session, calls)`](/sdk/execute) | None. |
| Verify | Any client reads `getActiveKeys` | None. Free, unlimited. |
| Revoke | [`revokeSession`](/sdk/revoke-session) | Write (gated by `onlyKeyOwnerOrValidator`). Session revoked (monotonic — cannot be reactivated). |
| Expire | Automatic at `expiry` | None. No transaction. |
Reads are free and unlimited, which is what makes cross-agent and cross-app verification practical.
### Notes
* **Sessions must be byte-exact on execute.** The on-chain validator matches `permissions + expiry + role + publicKey` exactly to the hash committed at grant time. Persist the `Session` object verbatim. Sloppy JSON round-trips (bigints to numbers, key reordering) break the match.
* **`permissions.calls` omitted = unrestricted.** If you don't pass `calls`, the session can call any contract within its spend cap. Set both unless that's truly what you want.