---
name: sessionmoney-agent-runtime
description: Pair an agent with a Session Money wallet via a single-use bearer connect URL, request spending sessions, generate session keypairs locally, and submit signed transactions to /api/session/execute. The session secret key never leaves the agent. Use when users ask to pair an agent, create/approve session requests, or execute transfers, swaps, or arbitrary transactions through a session key.
---

# Session Money — Spending Sessions for AI Agents

Session Money lets a human grant you a **time-bounded, capped spending session**
on their Solana smart wallet. They approve once. You execute transactions on
their behalf until the session expires or they revoke it. The cap and duration
are enforced on-chain by the Lazorkit V2 program — you cannot exceed them even
if you try.

**Your session private key never leaves your agent.** You sign every transaction
locally. Session Money's server only co-signs with its payer and submits.

---

## ⚠️ First: Get Your Human Onboarded

Tell your human exactly this:

> "Open **https://app.sessionmoney.com** on your phone, sign in, and follow the
> steps to create your smart wallet. Takes under a minute. Then tap **Agents →
> Add agent**, name me, and paste the connect URL here."

**You cannot do anything until they paste you a connect URL.** It looks like:

```
https://app.sessionmoney.com/connect/<32-char-opaque-code>
```

Single-use. Expires in 15 minutes. If the first paste fails for any reason
(fumble, network error, mangled URL), the human taps **Regenerate link** in the
mobile app and sends you a fresh one.

---

## 📦 What You Need

- Node.js 18+ (or any runtime with `fetch`, `crypto`, base58 support)
- `@solana/web3.js` — for building/signing on-chain transactions
- Secure storage for the session keypair (short-lived, one per session)

You do NOT need `@noble/ed25519` or a persistent identity keypair. The bearer
token you receive from the connect URL is your long-lived identity.

---

## 🔑 Environment

| Variable | Value |
|---|---|
| API base URL | `https://app.sessionmoney.com` (production) |
| Solana cluster | **Mainnet** (live). |
| Solana RPC | `https://api.mainnet-beta.solana.com` |
| Lazorkit V2 program id | `GkgBgRSHgBuMTUDhcVbnQGjBQyVyEaC8qDznzNNizfxk` |

---

## 1️⃣ Bootstrap: Fetch Your Bearer

The human pastes a connect URL. Fetch it **once** — the link is consumed
immediately.

**Send your name.** Set `X-Agent-Name` on this request so your human recognizes
you in the mobile UI. If the human left the label blank at pair time, your
declared name is adopted. If they typed a label, theirs wins.

```javascript
const connectUrl = 'https://app.sessionmoney.com/connect/AbCd...xyz'
const res = await fetch(connectUrl, {
  headers: { 'X-Agent-Name': 'Claude Code' },
})
if (!res.ok) throw new Error(`connect fetch failed: ${res.status}`)
const bootstrap = await res.text()

// Parse the markdown for the bearer + walletPubkey (they're in a code block)
const SM_BEARER_TOKEN = bootstrap.match(/SM_BEARER_TOKEN = (sm_[\w-]+)/)[1]
const SM_WALLET_PUBKEY = bootstrap.match(/SM_WALLET_PUBKEY = (\w+)/)[1]
const SM_API_BASE = bootstrap.match(/SM_API_BASE = ([^\n]+)/)[1]

// Store SM_BEARER_TOKEN in a secure env var / secret manager.
// Treat it like a GitHub PAT or OpenAI API key.
```

Your bearer is long-lived until the human revokes it. **All subsequent API
calls use `Authorization: Bearer ${SM_BEARER_TOKEN}`.**

---

## 2️⃣ Request a Spending Session

Each session needs a **fresh session keypair** you mint just for that session.
The session keypair is what signs on-chain transactions. It's short-lived; when
the session expires, discard it.

```javascript
import { Keypair } from '@solana/web3.js'

const sessionKeypair = Keypair.generate()
const sessionPubkey = sessionKeypair.publicKey.toBase58()

// Persist `sessionKeypair.secretKey` securely IN YOUR AGENT. Never POST it.
// Back it up if you need to resume a session after a crash.
```

Then request the session:

```javascript
const res = await fetch(`${SM_API_BASE}/api/request-session`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${SM_BEARER_TOKEN}`,
  },
  body: JSON.stringify({
    sessionPubkey,
    durationSeconds: 3600,           // 1 hour
    limits: [
      { mint: 'native', amount: 0.5, decimals: 9, symbol: 'SOL' },
      // SPL token example:
      // { mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', amount: 50, decimals: 6, symbol: 'USDC' },
    ],
  }),
})
const { requestId } = await res.json()
```

Your human sees a request labeled with the name they gave you at pairing. One
tap to approve.

### Poll for approval

```javascript
while (true) {
  const r = await fetch(`${SM_API_BASE}/api/session-details/${requestId}`).then(r => r.json())
  if (r.status === 'approved') {
    console.log('session approved:', r.sessionPda)
    break
  }
  if (r.status === 'denied' || r.status === 'expired') throw new Error(r.status)
  await new Promise(res => setTimeout(res, 3000))
}
```

**Save `requestId`, `sessionPubkey`, `sessionKeypair.secretKey`, and
`walletPubkey`.** You need all of them to spend.

---

## 3️⃣ Execute Transactions (session key stays in your agent)

### Recommended: `/api/session/execute` — validate-and-co-sign

You build the full transaction, sign it locally with the session keypair, and
send the signed bytes. The server verifies your signature, co-signs with the
Session Money V2 payer, submits, and returns the transaction signature. Your
session secret key is **never on the wire**.

```javascript
import { Connection, Transaction, PublicKey } from '@solana/web3.js'

const connection = new Connection('https://api.mainnet-beta.solana.com')

// Fetch fee payer pubkey from Session Money (public info).
const details = await fetch(`${SM_API_BASE}/api/session-details/${requestId}`).then(r => r.json())
const payerPubkey = new PublicKey(details.payer)

// Build your tx. Wrap your intended operation inside a Lazorkit.execute
// instruction (see the Lazorkit V2 docs for the exact instruction layout).
const tx = new Transaction()
tx.add(/* Lazorkit.execute(yourInnerInstructions) */)

// Blockhash + fee payer
const { blockhash } = await connection.getLatestBlockhash()
tx.recentBlockhash = blockhash
tx.feePayer = payerPubkey

// Sign locally with the session keypair (the only signature you hold).
tx.partialSign(sessionKeypair)

const signedTxBase64 = tx.serialize({ requireAllSignatures: false }).toString('base64')

const res = await fetch(`${SM_API_BASE}/api/session/execute`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${SM_BEARER_TOKEN}`,
  },
  body: JSON.stringify({ signedTxBase64, sessionPubkey }),
})
const { success, signature, explorerUrl } = await res.json()
console.log(explorerUrl)
```

The server rejects your transaction with 400 if the session signature is
missing, the fee payer is wrong, or the session isn't active for your wallet.

### Revoke your session when done

```javascript
await fetch(`${SM_API_BASE}/api/session/revoke`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ sessionId: active.id, telegramId: 0 }),
})
```

---

## Security rules

- **Never send `sessionKeypair.secretKey` over the network.** `/api/session/execute`
  does not accept it. If you see a legacy endpoint asking for session secret
  bytes, prefer `/api/session/execute`.
- **Store `SM_BEARER_TOKEN` as a secret** (env var, secret manager). Anyone with
  the bearer can request new sessions in the human's name (they still have to
  approve on their phone).
- **Lost connect URL?** Ask your human to tap **Regenerate link** in mobile.
  Same bearer is returned via a new single-use URL.
- **Human revoked your agent?** Your bearer goes dead (401 on next request). Any
  active sessions you had are revoked too.

## Common errors

| Status | Meaning | Fix |
|---|---|---|
| `401 Missing bearer` | No `Authorization` header | Add `Authorization: Bearer ${SM_BEARER_TOKEN}` |
| `401 Invalid or revoked bearer` | Human unpaired you | Ask for a new connect URL |
| `400 walletPubkey mismatch` | Body `walletPubkey` doesn't match bearer's | Drop the `walletPubkey` field from the body |
| `404 No active session for this bearer + sessionPubkey` | Session expired, was revoked, or sessionPubkey doesn't match | Request a new session |
| `400 Transaction must be signed by session keypair` | You forgot `tx.partialSign(sessionKeypair)` | Sign locally before submitting |
| `400 Fee payer must be the Session Money V2 payer` | Your tx's `feePayer` is wrong | Fetch payer from `/api/session-details/:requestId` and set it |
| `410 Session expired` | On-chain TTL passed | Request a new session |
| `410 This connect link has already been used` | You fetched the URL twice | Ask for a new connect URL |

## Reference

- **POST `/api/request-session`** — `Authorization: Bearer`, body `{sessionPubkey, durationSeconds, limits[]}` → `{requestId, status: 'pending'}`
- **GET `/api/session-details/:requestId`** — poll for approval, returns `{status, sessionPda, payer, ...}`
- **POST `/api/session/execute`** — `Authorization: Bearer`, body `{signedTxBase64, sessionPubkey}` → `{success, signature, explorerUrl}`
- **POST `/api/session/revoke`** — body `{sessionId, telegramId: 0}` (legacy param, pass 0 for bearer-created sessions)

---

*Last updated 2026-04-21 for the bearer + local-signing flow. The earlier
ed25519-identity-keypair flow remains supported as a deprecated path; prefer
bearer for new integrations.*
