Skip to main content

Overview

An adapter is a Domain A component that bridges an AI agent framework to the ISCL secure signing layer. Adapters construct declarative TxIntent objects, call ISCL Core over HTTP, and present results back to the agent or user. They never touch private keys, never sign transactions, and never call the blockchain directly. Every adapter in the Clavion ecosystem follows the same four-step contract:
  1. Accept user/agent input (natural language, command, tool call).
  2. Build a TxIntent describing the desired on-chain action.
  3. Send the intent through the ISCL Core API for policy checks, simulation, approval, signing, and broadcast.
  4. Return the result to the caller.
This tutorial walks through building a new adapter from scratch using the patterns established by the four existing adapters.

Architecture Pattern

Every adapter sits between the agent framework and ISCL Core, acting as a translation layer. Component overview:
+---------------------+       +------------------+       +-------------------+
|   Agent Framework   |       |     Adapter      |       |    ISCL Core      |
|  (MCP / Eliza /     | ----> |  (Domain A)      | ----> |   (Domain B)      |
|   Telegram / etc.)  |       |                  |       |                   |
+---------------------+       +-------+----------+       +--------+----------+
                                      |                           |
                              ISCLClient (HTTP)           PolicyEngine
                              buildIntent()               Preflight
                              executeSecurePipeline()     WalletService
                                                          AuditTrace
                                                          RPC access
Detailed data flow for a fund-affecting operation:
Agent Input
  |
  v
Adapter: parse parameters
  |
  v
Adapter: buildIntent({ walletAddress, action, chainId, constraints })
  |
  v
ISCLClient: POST /v1/tx/approve-request   -->  ISCL Core: policy + preflight + approval prompt
  |                                                       |
  v                                                       v
ISCLClient receives { approved, approvalTokenId }   <--  User approves/denies
  |
  v (if approved)
ISCLClient: POST /v1/tx/sign-and-send     -->  ISCL Core: verify token, sign, broadcast
  |                                                       |
  v                                                       v
Adapter: return { txHash, broadcast }      <--  Signed tx + receipt
The adapter never sees keys, never signs, and never contacts the blockchain. All chain access is mediated by ISCL Core.

Existing Adapters

All four adapters share the same core pattern: ISCLClient + buildIntent() + executeSecurePipeline(). The only differences are how they receive input and present output.
AdapterFrameworkPackageUse CaseKey Files
OpenClawOpenClaw runtime@clavion/adapter-openclawAI agent skillssrc/skills/, src/shared/iscl-client.ts
MCPModel Context Protocol@clavion/adapter-mcpClaude Desktop / Cursor / IDEssrc/server.ts, src/tools/
ElizaElizaOS (ai16z)@clavion/plugin-elizaAutonomous agentssrc/index.ts, src/actions/
TelegramgrammY@clavion/adapter-telegramChat-based approvalsrc/bot.ts, src/commands/
1

Package Setup

Create the package directory under packages/:
packages/adapter-myframework/
  src/
    shared/
      iscl-client.ts       # HTTP client (copy from existing adapter)
      intent-builder.ts    # TxIntent construction helper
      pipeline.ts          # 2-step approve-then-sign pipeline
    index.ts               # Entry point, exports
  test/
    handler.test.ts
  package.json
  tsconfig.json

package.json

{
  "name": "@clavion/adapter-myframework",
  "version": "0.1.0",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc -b",
    "test": "vitest run"
  },
  "dependencies": {
    "@clavion/types": "workspace:*"
  },
  "devDependencies": {
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
}
Only @clavion/types is needed from the monorepo. This package provides the TxIntent, ActionObject, and other shared interfaces. Never add Domain B packages (@clavion/signer, @clavion/policy, @clavion/audit, etc.) as dependencies. Doing so violates the trust domain boundary and will be caught by the domain-b-integrity.test.ts security test suite.

tsconfig.json

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "references": [
    { "path": "../types" }
  ]
}
The references array only points at ../types. This enforces the Domain A boundary at the TypeScript project-reference level — the compiler will refuse to resolve imports from packages not listed here.
2

ISCLClient

Copy src/shared/iscl-client.ts from any existing adapter. The client is framework-agnostic and identical across all adapters. It wraps the ISCL Core REST API with typed methods.

Constructor

import { ISCLClient } from "./shared/iscl-client.js";

// Uses ISCL_API_URL env var, or defaults to http://127.0.0.1:3000
const client = new ISCLClient();

// Or provide explicit options
const client = new ISCLClient({
  baseUrl: "http://localhost:3000",
  timeoutMs: 15_000,
});
The constructor reads the base URL from three sources in priority order:
  1. options.baseUrl (explicit)
  2. ISCL_API_URL environment variable
  3. http://127.0.0.1:3000 (default)

Key Methods

MethodHTTP CallPurpose
health()GET /v1/healthConnectivity check, returns version + uptime
txBuild(intent)POST /v1/tx/buildBuild transaction (policy check only, no signing)
txPreflight(intent)POST /v1/tx/preflightSimulate + risk score
txApproveRequest(intent)POST /v1/tx/approve-requestFull pipeline: build + preflight + user prompt
txSignAndSend(payload)POST /v1/tx/sign-and-sendSign + broadcast (requires approval token)
balance(token, account, chainId?)GET /v1/balance/:token/:accountRead ERC-20 or native balance
txReceipt(hash)GET /v1/tx/:hashLook up transaction receipt

Error Handling

All methods throw ISCLError on non-2xx responses:
import { ISCLError } from "./shared/iscl-client.js";

try {
  const result = await client.txApproveRequest(intent);
} catch (err) {
  if (err instanceof ISCLError) {
    console.error(`ISCL API error ${err.status}:`, err.body);
    // err.status — HTTP status code (400, 403, 422, 500, etc.)
    // err.body   — parsed JSON response body with error details
  }
}
Common error codes:
StatusMeaning
400Invalid TxIntent (schema validation failed)
403Policy denied the transaction
404Resource not found (e.g., unknown tx hash)
422Preflight simulation reverted
500Internal server error
3

Intent Builder

The intent builder converts framework-specific parameters into a TxIntent object. Every adapter has one, and they are nearly identical.
import { randomUUID } from "node:crypto";
import type { TxIntent, ActionObject } from "@clavion/types";

export interface IntentBuilderOptions {
  walletAddress: string;
  action: ActionObject;
  chainId?: number;       // Default: 8453 (Base)
  maxGasWei?: string;     // Default: "1000000000000000" (0.001 ETH)
  deadline?: number;      // Default: now + 600 (10 minutes)
  slippageBps?: number;   // Default: 100 (1%)
  source?: string;        // Adapter identifier for audit trail
}

export function buildIntent(options: IntentBuilderOptions): TxIntent {
  const now = Math.floor(Date.now() / 1000);
  return {
    version: "1",
    id: randomUUID(),
    timestamp: now,
    chain: {
      type: "evm",
      chainId: options.chainId ?? 8453,
    },
    wallet: { address: options.walletAddress },
    action: options.action,
    constraints: {
      maxGasWei: options.maxGasWei ?? "1000000000000000",
      deadline: options.deadline ?? now + 600,
      maxSlippageBps: options.slippageBps ?? 100,
    },
    metadata: { source: options.source ?? "myframework-adapter" },
  };
}

Building Actions for Each Type

import type { TransferAction } from "@clavion/types";

const action: TransferAction = {
  type: "transfer",
  asset: { kind: "erc20", address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" },
  to: "0xRecipientAddress...",
  amount: "1000000",  // In token base units (e.g., 1 USDC = 1000000)
};
4

Handler Functions -- The 2-Step Pipeline

Every fund-affecting operation follows the same two-step pipeline:
  1. txApproveRequest(intent) — Sends the intent to ISCL Core, which runs policy checks, preflight simulation, and prompts the user for approval. This call blocks until the user approves or denies.
  2. txSignAndSend({ intent, approvalTokenId }) — If approved, sends the intent with the single-use approval token. ISCL Core verifies the token, signs the transaction, and broadcasts it.

Shared Pipeline Function

Extract this into src/shared/pipeline.ts so all handlers share the same logic:
import type { ISCLClient } from "./iscl-client.js";
import type { TxIntent } from "@clavion/types";

export interface PipelineResult {
  success: boolean;
  intentId: string;
  approved: boolean;
  txHash?: string;
  broadcast?: boolean;
  broadcastError?: string;
  description?: string;
  riskScore?: number;
  riskReasons?: string[];
  declineReason?: string;
}

export async function executeSecurePipeline(
  intent: TxIntent,
  client: ISCLClient,
): Promise<PipelineResult> {
  // Step 1: approve-request (policy + preflight + user prompt)
  const approval = await client.txApproveRequest(intent);

  if (!approval.approved) {
    return {
      success: false,
      intentId: approval.intentId,
      approved: false,
      description: approval.description,
      riskScore: approval.riskScore,
      riskReasons: approval.riskReasons,
      declineReason: approval.reason ?? "user_declined",
    };
  }

  // Step 2: sign-and-send (with single-use approval token)
  const signed = await client.txSignAndSend({
    intent,
    approvalTokenId: approval.approvalTokenId,
  });

  return {
    success: true,
    intentId: signed.intentId,
    approved: true,
    txHash: signed.txHash,
    broadcast: signed.broadcast,
    broadcastError: signed.broadcastError,
    description: approval.description,
    riskScore: approval.riskScore,
    riskReasons: approval.riskReasons,
  };
}

Complete Handler Example

import type { ISCLClient } from "./shared/iscl-client.js";
import type { TransferAction } from "@clavion/types";
import { ISCLError } from "./shared/iscl-client.js";
import { buildIntent } from "./shared/intent-builder.js";
import { executeSecurePipeline } from "./shared/pipeline.js";

interface TransferParams {
  wallet: string;
  tokenAddress: string;
  recipient: string;
  amount: string;
  chainId?: number;
}

async function handleTransfer(params: TransferParams, client: ISCLClient) {
  try {
    const action: TransferAction = {
      type: "transfer",
      asset: { kind: "erc20", address: params.tokenAddress },
      to: params.recipient,
      amount: params.amount,
    };

    const intent = buildIntent({
      walletAddress: params.wallet,
      action,
      chainId: params.chainId,
      source: "myframework-adapter",
    });

    const result = await executeSecurePipeline(intent, client);

    if (!result.success) {
      return {
        error: `Denied: ${result.declineReason}`,
        riskScore: result.riskScore,
      };
    }

    return {
      txHash: result.txHash,
      broadcast: result.broadcast,
      intentId: result.intentId,
    };
  } catch (err) {
    if (err instanceof ISCLError) {
      return { error: `ISCL error ${err.status}: ${JSON.stringify(err.body)}` };
    }
    throw err;
  }
}

Read-Only Operations

Balance checks and transaction lookups do not require the approval pipeline:
async function handleBalance(
  tokenAddress: string,
  walletAddress: string,
  client: ISCLClient,
  chainId?: number,
) {
  const result = await client.balance(tokenAddress, walletAddress, chainId);
  return { token: result.token, balance: result.balance };
}
5

Framework Integration

Each framework has its own way of registering tools, commands, or actions. Below are the patterns used by the four existing adapters.
Register tools on an McpServer instance. Each tool has a name, description, Zod schema, and async handler.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({ name: "clavion-iscl", version: "0.1.0" });
const client = new ISCLClient();

server.tool(
  "clavion_transfer",
  "Transfer ERC-20 tokens securely through Clavion.",
  {
    wallet: { type: "string", description: "Sender wallet address" },
    to: { type: "string", description: "Recipient address" },
    asset: { type: "object", properties: { kind: {}, address: {}, symbol: {} } },
    amount: { type: "string", description: "Amount in base units" },
    chainId: { type: "number", description: "Chain ID (default 8453)" },
  },
  async (args) => handleTransfer(args, client),
);
Key file: packages/adapter-mcp/src/server.ts
6

Testing

Unit Tests: Mock the ISCLClient

Test intent construction and handler logic without a running ISCL server:
import { describe, it, expect, vi } from "vitest";
import { buildIntent } from "../src/shared/intent-builder.js";
import type { ISCLClient } from "../src/shared/iscl-client.js";

describe("buildIntent", () => {
  it("creates a valid TxIntent with defaults", () => {
    const intent = buildIntent({
      walletAddress: "0xAlice",
      action: {
        type: "transfer",
        asset: { kind: "erc20", address: "0xUSDC" },
        to: "0xBob",
        amount: "1000000",
      },
    });

    expect(intent.version).toBe("1");
    expect(intent.chain.chainId).toBe(8453);
    expect(intent.action.type).toBe("transfer");
    expect(intent.constraints.maxSlippageBps).toBe(100);
    expect(intent.metadata?.source).toBe("myframework-adapter");
  });
});

describe("handleTransfer", () => {
  it("returns txHash on approval", async () => {
    const mockClient = {
      txApproveRequest: vi.fn().mockResolvedValue({
        intentId: "intent-1",
        approved: true,
        approvalTokenId: "token-1",
        description: "Transfer 1 USDC",
        riskScore: 15,
        riskReasons: [],
        warnings: [],
        gasEstimate: "21000",
        policyDecision: { decision: "allow", reasons: [], policyVersion: "1" },
      }),
      txSignAndSend: vi.fn().mockResolvedValue({
        intentId: "intent-1",
        txHash: "0xabc...",
        signedTx: "0x...",
        broadcast: true,
      }),
    } as unknown as ISCLClient;

    const result = await handleTransfer(
      { wallet: "0xAlice", tokenAddress: "0xUSDC", recipient: "0xBob", amount: "1000000" },
      mockClient,
    );

    expect(result.txHash).toBe("0xabc...");
    expect(mockClient.txApproveRequest).toHaveBeenCalledOnce();
    expect(mockClient.txSignAndSend).toHaveBeenCalledWith(
      expect.objectContaining({ approvalTokenId: "token-1" }),
    );
  });

  it("returns error when user denies", async () => {
    const mockClient = {
      txApproveRequest: vi.fn().mockResolvedValue({
        intentId: "intent-2",
        approved: false,
        reason: "user_declined",
        riskScore: 45,
        riskReasons: ["high_value"],
      }),
    } as unknown as ISCLClient;

    const result = await handleTransfer(
      { wallet: "0xAlice", tokenAddress: "0xUSDC", recipient: "0xBob", amount: "999999999" },
      mockClient,
    );

    expect(result.error).toContain("Denied");
    expect(mockClient.txApproveRequest).toHaveBeenCalledOnce();
  });
});

Integration Tests: Real ISCL Server

For integration tests, spin up an ephemeral buildApp() server and test the full round-trip:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "@clavion/core";
import { ISCLClient } from "../src/shared/iscl-client.js";

describe("adapter integration", () => {
  let app: Awaited<ReturnType<typeof buildApp>>;
  let client: ISCLClient;

  beforeAll(async () => {
    app = await buildApp({
      rpc: mockRpcClient,
      promptFn: async () => true,  // Auto-approve for tests
    });
    await app.listen({ port: 0 });
    const port = (app.server.address() as { port: number }).port;
    client = new ISCLClient({ baseUrl: `http://127.0.0.1:${port}` });
  });

  afterAll(async () => {
    await app.close();
  });

  it("completes a transfer through the full pipeline", async () => {
    const intent = buildIntent({
      walletAddress: "0xTestWallet",
      action: { type: "transfer_native", to: "0xRecipient", amount: "1000" },
    });

    const result = await executeSecurePipeline(intent, client);
    expect(result.success).toBe(true);
    expect(result.txHash).toBeDefined();
  });
});
Pass promptFn: async () =&gt; true to buildApp() to auto-approve all transactions in tests. Without this, tests will hang waiting for interactive readline input.

Security Checklist

Every adapter must satisfy these five invariants. Violations break the trust model and will be caught by the domain-b-integrity.test.ts security test suite.
#RuleWhat to Check
1Never import Domain B packagesNo imports from @clavion/signer, @clavion/policy, @clavion/audit, @clavion/preflight, @clavion/registry, or @clavion/sandbox. The only allowed monorepo dependency is @clavion/types.
2Never access private keysNo reading key files, no process.env.PRIVATE_KEY, no key material in memory. The adapter has no concept of a private key.
3Never make direct RPC/blockchain callsNo viem client creation, no ethers.JsonRpcProvider, no fetch("https://rpc.example.com"). All chain access goes through the ISCL Core API (/v1/balance/, /v1/tx/, etc.).
4Never construct raw calldataNo encodeFunctionData, no ABI encoding, no raw data fields. Use the declarative TxIntent format and let ISCL Core build the transaction.
5Always handle ISCLError responsesCheck for ISCLError, display policy deny reasons to the user, and never silently swallow errors. A policy denial is not a bug — it is the system working correctly.

Complete Minimal Example

A self-contained adapter that works with any framework. This example implements a transfer handler in approximately 60 lines:
// src/index.ts — Complete minimal adapter
import { randomUUID } from "node:crypto";
import type { TxIntent, TransferAction } from "@clavion/types";

// ---------- ISCLClient (simplified) ----------

class ISCLClient {
  constructor(private baseUrl = process.env["ISCL_API_URL"] ?? "http://127.0.0.1:3000") {}

  private async post<T>(path: string, data: unknown): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: { "content-type": "application/json", accept: "application/json" },
      body: JSON.stringify(data),
    });
    const body = await res.json();
    if (!res.ok) throw new Error(`ISCL ${res.status}: ${JSON.stringify(body)}`);
    return body as T;
  }

  txApproveRequest(intent: TxIntent) {
    return this.post<{
      approved: boolean;
      approvalTokenId?: string;
      intentId: string;
      reason?: string;
    }>("/v1/tx/approve-request", intent);
  }

  txSignAndSend(payload: { intent: TxIntent; approvalTokenId?: string }) {
    return this.post<{ txHash: string; broadcast: boolean; intentId: string }>(
      "/v1/tx/sign-and-send",
      payload,
    );
  }
}

// ---------- Intent builder ----------

function buildIntent(wallet: string, action: TransferAction, chainId = 8453): TxIntent {
  const now = Math.floor(Date.now() / 1000);
  return {
    version: "1",
    id: randomUUID(),
    timestamp: now,
    chain: { type: "evm", chainId },
    wallet: { address: wallet },
    action,
    constraints: {
      maxGasWei: "1000000000000000",
      deadline: now + 600,
      maxSlippageBps: 100,
    },
    metadata: { source: "minimal-adapter" },
  };
}

// ---------- Transfer handler ----------

export async function transfer(
  wallet: string,
  token: string,
  to: string,
  amount: string,
  chainId?: number,
) {
  const client = new ISCLClient();
  const action: TransferAction = {
    type: "transfer",
    asset: { kind: "erc20", address: token },
    to,
    amount,
  };
  const intent = buildIntent(wallet, action, chainId);

  const approval = await client.txApproveRequest(intent);
  if (!approval.approved) return { error: `Denied: ${approval.reason ?? "user_declined"}` };

  const result = await client.txSignAndSend({
    intent,
    approvalTokenId: approval.approvalTokenId,
  });
  return { txHash: result.txHash, broadcast: result.broadcast };
}
To integrate this with your framework, call transfer() from whatever command, tool, or action handler your framework provides.

Further Reading