> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chipipay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent with a session key and a passkey

> Let a server-side agent spend from a user's self-custodial wallet within on-chain caps: one passkey prompt to hire it, one to stop it.

An agent (a cron job, an AI loop, a bot) acts on a user's wallet without holding the user's key. The user approves it once with their passkey. After that, the agent signs with a **session key** that the wallet contract limits on its own: which functions it can call, how many times, until when, and how much of each token it can move.

This guide builds that flow with `@chipi-stack/nextjs` in the browser and `@chipi-stack/backend` on the server. It needs **14.14.0** or later.

## What runs where

| Step | Where | Signs with | Prompts |
| - | - | - | - |
| Create the session keypair | Server | nothing (local keygen) | none |
| Register the session **and** its caps | Browser | owner key, unlocked by the passkey | **1 passkey prompt** |
| Decide and execute | Server | session key | none |
| Stop the agent | Browser | owner key, unlocked by the passkey | **1 passkey prompt** |

The owner key never leaves the browser, and the session key never leaves the server.

<Info>
  Session keys work on **CHIPI** wallets and on **SHHH** wallets with a STARK or ED25519 signer, which includes every SHHH wallet the SDK creates with a passkey. Check `supportsSession` from `useChipiSession`, or `walletSupportsSessions(wallet)`.
</Info>

## 1. Server: create the session key

The session private key is encrypted with a secret only your server knows. Store the whole `SessionKeyData` in your database; send only the public key and expiry to the browser.

```ts theme={null}
import { ChipiServerSDK } from "@chipi-stack/backend";
import type { SessionKeyData } from "@chipi-stack/types";

const sdk = new ChipiServerSDK({
  apiPublicKey: process.env.CHIPI_API_PUBLIC_KEY!,
  apiSecretKey: process.env.CHIPI_API_SECRET_KEY!,
});

export async function createAgentSession(
  userId: string,
  save: (userId: string, session: SessionKeyData) => Promise<void>
) {
  const session = sdk.sessions.createSessionKey({
    encryptKey: process.env.AGENT_SESSION_SECRET!,
    durationSeconds: 7 * 24 * 60 * 60, // 7 days
  });
  await save(userId, session);

  // Only the public part goes to the browser.
  return { sessionPublicKey: session.publicKey, validUntil: session.validUntil };
}
```

## 2. Browser: register the session and its caps with one passkey prompt

`getEncryptKey` runs only when an action needs the owner key, so the passkey prompt appears when the user clicks, not when the page loads. It runs **before** `getBearerToken`, so the JWT is fresh after the prompt. Every action resolves it again: if you also execute from the browser (`executeWithSession`), each execution prompts. That is why the agent here executes on the server.

With `spendingPolicies`, the session and every cap are registered in **one** owner-signed transaction: the session never exists on-chain without its limits.

```tsx theme={null}
import { useAuth } from "@clerk/nextjs";
import { getWalletEncryptKey, useChipiSession, useChipiWallet } from "@chipi-stack/nextjs";

const USDC = "0x033068f6539f8e6e6b131e6b2b814e6c34a5224bc66947c47dab9dfee93b35fb";

export function HireAgent({ credentialId }: { credentialId: string }) {
  const { userId, getToken } = useAuth();
  const { wallet } = useChipiWallet({
    externalUserId: userId ?? null,
    getBearerToken: getToken,
  });

  const { registerSession, supportsSession, isRegistering } = useChipiSession({
    wallet,
    getEncryptKey: () => getWalletEncryptKey(credentialId),
    getBearerToken: () => getToken({ skipCache: true }),
  });

  const hire = async () => {
    const res = await fetch("/api/agent/session", { method: "POST" });
    const { sessionPublicKey, validUntil } = await res.json();

    await registerSession({
      sessionPublicKey,
      validUntil,
      maxCalls: 500,
      allowedEntrypoints: ["transfer", "approve", "multi_route_swap"],
      spendingPolicies: [
        // At most 5 USDC per call and 20 USDC per day.
        { token: USDC, maxPerCall: 5_000_000n, maxPerWindow: 20_000_000n, windowSeconds: 86_400 },
      ],
    });
  };

  if (!supportsSession) return <p>This wallet can't use an agent.</p>;
  return (
    <button onClick={hire} disabled={isRegistering}>
      Hire agent
    </button>
  );
}
```

<Warning>
  * **`maxPerWindow: 0n` means no cap**, not "nothing". Always set a positive window cap.
  * The whitelist is **not scoped to a contract**: `"transfer"` allows `transfer` on every token the wallet holds. Set a policy for every token the wallet may hold (for example USDC, ETH and STRK), not only the one the agent is meant to use.
  * Policies meter `transfer`, `approve` and `increase_allowance`. A swap is capped through its `approve`.
</Warning>

Function names (`"transfer"`) are converted to selectors for you. Hex selectors work too.

## 3. Server: decide and execute

The agent reads the market with `sdk.ai`, checks the decision, builds the swap and signs it with the session key. `sdk.ai` uses your secret key and refuses to run without one.

```ts theme={null}
import { ChipiServerSDK, isThinkDecision, waitForTransaction } from "@chipi-stack/backend";
import type { SessionKeyData } from "@chipi-stack/types";

const sdk = new ChipiServerSDK({
  apiPublicKey: process.env.CHIPI_API_PUBLIC_KEY!,
  apiSecretKey: process.env.CHIPI_API_SECRET_KEY!,
});

export async function tick(userId: string, session: SessionKeyData, maxTradeUsd: number) {
  const owner = await sdk.getWallet({ externalUserId: userId });
  if (!owner) throw new Error(`No wallet for ${userId}`);

  const { decision } = await sdk.ai.think({
    portfolio: { walletAddress: owner.publicKey },
    riskScore: 3,
  });

  // A non-JSON reply or an unknown action is not an order: hold.
  if (!isThinkDecision(decision) || decision.action !== "swap" || !decision.from || !decision.to) {
    return { action: "hold" as const };
  }

  // Your limits, not the model's.
  const amountUsd = Math.min(decision.amount ?? 0, maxTradeUsd);
  if (amountUsd <= 0) return { action: "hold" as const };

  const { calls } = await sdk.ai.execute({
    chain: "starknet",
    action: "swap",
    from: decision.from,
    to: decision.to,
    amountUsd,
    walletAddress: owner.publicKey,
    slippage: 0.01,
  });

  const txHash = await sdk.executeTransactionWithSession({
    params: {
      encryptKey: process.env.AGENT_SESSION_SECRET!,
      wallet: owner,
      session,
      calls,
    },
  });

  // A hash is not a result: read the receipt.
  const receipt = await waitForTransaction(txHash);
  return { action: "swap" as const, txHash, success: receipt.success };
}
```

`sdk.getWallet` with the server SDK returns the wallet row, including `walletType` and `signerKind`, which is what the session execution routes on.

## 4. Get paid by the agent (x402 without a facilitator)

If the agent pays your API per call, let it send the transfer with its session key and hand you the hash. `SessionTxVerifier` accepts it only if the transaction succeeded, its `Transfer` event moved at least the price from the agent's wallet to you, and the hash was never used before.

<Warning>
  Hashes and `Transfer` events are public. Take `agentWallet` from the caller's authenticated identity, not from the request body: otherwise anyone watching your treasury can submit an agent's hash first and use the payment. Anonymous payers are first-come-first-served, so keep that to low-value calls.
</Warning>

```ts theme={null}
import { SessionTxVerifier, type TxHashStore } from "@chipi-stack/backend";

const USDC = "0x033068f6539f8e6e6b131e6b2b814e6c34a5224bc66947c47dab9dfee93b35fb";

export function createPaymentCheck(store: TxHashStore, treasury: string) {
  // Back `store` with a unique index in your database in production.
  const verifier = new SessionTxVerifier({ txHashStore: store });

  return async (transactionHash: string, agentWallet: string) => {
    const result = await verifier.verifyTransfer({
      transactionHash,
      token: USDC,
      from: agentWallet,
      to: treasury,
      minAmount: 1_000n, // $0.001
    });
    return result.valid ? null : result.reason; // REVERTED, TRANSFER_NOT_FOUND, AMOUNT_TOO_LOW, ALREADY_USED
  };
}
```

## 5. Browser: stop the agent

Revoking needs the owner key, so it is one more passkey prompt. Pass the session public key: the browser never held the session itself.

```tsx theme={null}
import { useAuth } from "@clerk/nextjs";
import { getWalletEncryptKey, useChipiSession, useChipiWallet } from "@chipi-stack/nextjs";

export function StopAgent({ credentialId, sessionPublicKey }: { credentialId: string; sessionPublicKey: string }) {
  const { userId, getToken } = useAuth();
  const { wallet } = useChipiWallet({ externalUserId: userId ?? null, getBearerToken: getToken });
  const { revokeSession, isRevoking } = useChipiSession({
    wallet,
    getEncryptKey: () => getWalletEncryptKey(credentialId),
    getBearerToken: () => getToken({ skipCache: true }),
  });

  return (
    <button onClick={() => revokeSession(sessionPublicKey)} disabled={isRevoking}>
      Stop agent
    </button>
  );
}
```

After revocation the contract zeroes the session, so `getSessionData` reads `validUntil: 0` and `isActive: false`.

## Check the session from the server

```ts theme={null}
import { ChipiServerSDK } from "@chipi-stack/backend";

const sdk = new ChipiServerSDK({
  apiPublicKey: process.env.CHIPI_API_PUBLIC_KEY!,
  apiSecretKey: process.env.CHIPI_API_SECRET_KEY!,
});

export async function sessionStatus(walletAddress: string, sessionPublicKey: string) {
  const data = await sdk.sessions.getSessionData({ walletAddress, sessionPublicKey });
  // isActive: registered, not expired and with calls left. remainingCalls: maxCalls - callsUsed.
  return { active: data.isActive, remainingCalls: data.remainingCalls, validUntil: data.validUntil };
}
```

`getSessionData` reads the latest accepted block. Right after registering, executing or revoking, wait for that transaction with `waitForTransaction(txHash)` before reading: until its block is accepted on L2, the read returns the previous state (or, for a wallet the transaction deploys, fails because the contract is not there yet).

## Without React

The same registration is available as pure call builders, so you can sign it with any owner path:

```ts theme={null}
import { buildSessionSetupCalls } from "@chipi-stack/backend";

const USDC = "0x033068f6539f8e6e6b131e6b2b814e6c34a5224bc66947c47dab9dfee93b35fb";

export function setupCalls(walletAddress: string, sessionPublicKey: string, validUntil: number) {
  // [add_or_update_session_key, set_spending_policy(USDC)]: one multicall, one signature.
  return buildSessionSetupCalls(
    walletAddress,
    { sessionPublicKey, validUntil, maxCalls: 500, allowedEntrypoints: ["transfer", "approve"] },
    [{ token: USDC, maxPerCall: 5_000_000n, maxPerWindow: 20_000_000n, windowSeconds: 86_400 }]
  );
}
```

`sdk.sessions.setupSession(params, bearerToken)` builds and signs the same calls with a PIN-encrypted owner key on the server.

## Related

* [useChipiSession](/sdk/nextjs/hooks/use-chipi-session)
* [Spending policies](/sdk/guides/spending-policies)
* [AI API](/services/ai-api/overview)
* [Passkeys](/sdk/guides/passkeys)

> ✅ Verified against the packed 14.14.0 types on **2026-09-28**.
