Skip to content

Latest commit

 

History

History

.cursor

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

Writing XMTP Agents

You're an expert in writing TypeScript with Node.js. Generate high-quality XMTP Agents that adhere to the following best practices:

Guidelines

  1. Use modern TypeScript patterns and ESM modules. All examples should be structured as ES modules with import statements rather than CommonJS require().

  2. Use the XMTP node-sdk v1.0.2 or newer, which offers enhanced functionality including group conversations.

  3. Only import from @xmtp/node-sdk for XMTP functionality. Do not import from any other XMTP-related packages or URLs. Specifically:

    • Never use the deprecated @xmtp/xmtp-js library, which has been completely replaced by @xmtp/node-sdk
    • Always import directly from @xmtp/node-sdk as shown below:
    // CORRECT:
    import { Client, type Conversation, type XmtpEnv } from "@xmtp/node-sdk";
    
    // INCORRECT - DEPRECATED:
    import { Client } from "@xmtp/xmtp-js";
    import { XmtpClient } from "some-other-package";
  4. Follow the consistent pattern for initializing XMTP clients:

    const signer = createSigner(WALLET_KEY);
    const encryptionKey = getEncryptionKeyFromHex(ENCRYPTION_KEY);
    const client = await Client.create(signer, encryptionKey, { env });
  5. Use proper environment variable validation at the start of each application. Check for required environment variables and show descriptive errors if missing.

  6. Never use the concept of "topic" when working with XMTP. The current SDK doesn't use topics for message organization - work directly with conversations, groups, and DMs instead.

  7. Handle both Group and DM conversations properly. The Group and Dm classes extend the Conversation class and provide specific functionality:

    if (conversation instanceof Group) {
      // Group-specific functionality like group.name or group.addMembers
    } else if (conversation instanceof Dm) {
      // DM-specific functionality like conversation.peerInboxId
    }
  8. Always sync conversations before streaming messages:

    await client.conversations.sync();
    const stream = client.conversations.streamAllMessages();
  9. Filter out messages from the agent itself to prevent endless loops:

    if (message?.senderInboxId.toLowerCase() === client.inboxId.toLowerCase()) {
      continue;
    }
  10. Consistent error handling pattern with try/catch blocks and specific error messages.

  11. Use the helper functions from the shared helpers directory for common operations:

    • createSigner - Creates a signer from a private key
    • getEncryptionKeyFromHex - Converts a hex string to an encryption key
  12. Always import helpers from the @helpers path, not from a relative path:

    // CORRECT:
    import { createSigner, getEncryptionKeyFromHex } from "@helpers";
    
    // INCORRECT:
    import { createSigner, getEncryptionKeyFromHex } from "./helpers";
  13. When creating a group, use the correct options interface:

    // CORRECT:
    const group = await client.conversations.newGroup([inboxId], {
      groupName: "My Group Name",
      groupDescription: "My group description",
      groupImageUrlSquare: "https://example.com/image.jpg",
    });
    
    // INCORRECT:
    const group = await client.conversations.newGroup([inboxId], {
      metadata: {
        name: "My Group Name",
      },
    });
  14. When checking message content types, use string literals instead of importing a non-existent ContentTypeId enum:

    // CORRECT:
    if (message?.contentType?.typeId !== "text") {
      continue;
    }
    
    // INCORRECT:
    import { ContentTypeId } from "@xmtp/node-sdk";
    if (message?.contentType?.typeId !== ContentTypeId.Text) {
      continue;
    }
  15. Get information about a message sender by using the conversation's members method, not by trying to call a non-existent sender() method on the message:

    // CORRECT: Use inboxStateFromInboxIds to get the address from the inboxId
    const inboxState = await client.preferences.inboxStateFromInboxIds([
      message.senderInboxId,
    ]);
    // assuming there is 1 only one identifier
    const addressFromInboxId = inboxState[0].identifiers[0].identifier;
    
    // INCORRECT:
    const sender = await message.sender();
    const senderAddress = sender.accountIdentifiers[0].identifier;
  16. Remember that ALL conversation types (including DMs) have a members() method. Don't check for "members" in an object:

    // CORRECT:
    const members = await conversation.members();
    
    // INCORRECT:
    if (conversation && "members" in conversation) {
      const members = await conversation.members();
    }
  17. Always use toLowerCase() when comparing inboxIds or addresses:

    // CORRECT:
    if (message.senderInboxId.toLowerCase() === client.inboxId.toLowerCase()) {
      continue;
    }
    
    // INCORRECT:
    if (message.senderInboxId === client.inboxId) {
      continue;
    }
  18. Use consistent error handling pattern with type narrowing for unknown errors:

    // CORRECT:
    try {
      // code that might throw
    } catch (error: unknown) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      console.error("Error:", errorMessage);
    }
    
    // INCORRECT:
    try {
      // code that might throw
    } catch (error) {
      console.error("Error:", error);
    }
  19. Always use the built-in key generation command instead of creating your own script:

Environment variables

To run your XMTP agent, you must create a .env file with the following variables:

WALLET_KEY= # the private key of the wallet
ENCRYPTION_KEY= # encryption key for the local database
XMTP_ENV= # local, dev, production

Generating XMTP Keys

Always use the built-in key generation command instead of creating your own script:

# Generate generic keys
yarn gen:keys

# Generate keys for a specific user
yarn gen:keys <name>

This command will:

  1. Generate a secure wallet private key
  2. Create an encryption key for the local database
  3. Output the corresponding public key
  4. Automatically append the keys to your .env file

Example output in .env:

# Generic keys
WALLET_KEY=0x...
ENCRYPTION_KEY=...
# public key is 0x...

# User-specific keys
# alice
WALLET_KEY_ALICE=0x...
ENCRYPTION_KEY_ALICE=...
# public key is 0x...

Important

Never create your own key generation script. The built-in command follows security best practices and uses the correct dependencies (@xmtp/node-sdk v1.0.2).

Example: XMTP Group Creator Agent

Prompt:

"Create an XMTP agent that creates a group for each message received that includes the sender (by inbox ID) and adds this member by address 0x7c40611372d354799d138542e77243c284e460b2. All members should be admins. The group name should be 'New group {message content}' with content being dynamic and the text message itself. After creating the group, it sends a message with the inbox ID, address, and installation ID of each user."

Solution:

import "dotenv/config";
import { createSigner, getEncryptionKeyFromHex } from "@helpers";
import { Client, IdentifierKind, type XmtpEnv } from "@xmtp/node-sdk";

// Environment variables validation
const { WALLET_KEY, ENCRYPTION_KEY, XMTP_ENV } = process.env;
if (!WALLET_KEY) throw new Error("WALLET_KEY must be set");
if (!ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY must be set");
if (!XMTP_ENV) throw new Error("XMTP_ENV must be set");

// Define the address to always add to new groups
const MEMBER_ADDRESS = "0x7c40611372d354799d138542e77243c284e460b2";

// Initialize client
const signer = createSigner(WALLET_KEY);
const encryptionKey = getEncryptionKeyFromHex(ENCRYPTION_KEY);
const env: XmtpEnv = process.env.XMTP_ENV as XmtpEnv;

async function main() {
  console.log(`Creating client on the '${env}' network...`);
  const client = await Client.create(signer, encryptionKey, { env });

  console.log("Syncing conversations...");
  await client.conversations.sync();

  // Log connection details
  const identifier = await signer.getIdentifier();
  const address = identifier.identifier;
  console.log(
    `Group Creator Agent initialized on ${address}\nSend a message on http://xmtp.chat/dm/${address}?env=${env}`,
  );

  // Start listening for messages
  console.log("Waiting for messages...");
  const stream = client.conversations.streamAllMessages();

  for await (const message of await stream) {
    /* Ignore messages from the same agent or non-text messages */
    if (
      message?.senderInboxId.toLowerCase() === client.inboxId.toLowerCase() ||
      message?.contentType?.typeId !== "text"
    ) {
      continue;
    }

    try {
      // Get message content and sender inbox ID
      const messageContent = message.content as string;
      console.log(`Received message: ${messageContent}`);
      const senderInboxId = message.senderInboxId;

      // Get the conversation to reply to the sender
      const conversation = await client.conversations.getConversationById(
        message.conversationId,
      );

      if (!conversation) {
        console.log("Could not find the conversation for the message");
        continue;
      }

      // Create a group name based on the message content
      const groupName = `New group ${messageContent}`;

      // Create a new group including the sender and the specified address
      console.log(
        `Creating group "${groupName}" with sender ${senderInboxId}...`,
      );

      // Create group with sender first
      const group = await client.conversations.newGroup([senderInboxId], {
        groupName: groupName,
        groupDescription: "Group created by message agent",
      });

      // Add the specified address as a member
      await group.addMembersByIdentifiers([
        {
          identifier: MEMBER_ADDRESS,
          identifierKind: IdentifierKind.Ethereum,
        },
      ]);

      // Get group members for response
      const members = await group.members();
      const memberDetails = [];

      for (const member of members) {
        let ethAddress = "Unknown";
        const ethIdentifier = member.accountIdentifiers.find(
          (id) => id.identifierKind === IdentifierKind.Ethereum,
        );

        if (ethIdentifier) {
          ethAddress = ethIdentifier.identifier;
        }

        let installationId = "Unknown";
        if (member.installationIds && member.installationIds.length > 0) {
          installationId = member.installationIds[0];
        }

        memberDetails.push({
          inboxId: member.inboxId,
          address: ethAddress,
          installationId: installationId,
        });
      }

      // Make all members admins
      for (const member of members) {
        if (member.inboxId.toLowerCase() !== client.inboxId.toLowerCase()) {
          await group.addAdmin(member.inboxId);
        }
      }

      // Send member details as response in the group
      const responseMessage = `Group created with members:\n${memberDetails
        .map(
          (m) =>
            `- Inbox ID: ${m.inboxId}\n  Address: ${m.address}\n  Installation ID: ${m.installationId}`,
        )
        .join("\n\n")}`;

      await group.send(responseMessage);

      // Reply to original conversation
      await conversation.send(
        `Created group "${groupName}" with you and ${MEMBER_ADDRESS}. All members have admin privileges.`,
      );

      console.log(
        `Group "${groupName}" created successfully with ${members.length} members`,
      );
    } catch (error: unknown) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      console.error("Error processing message:", errorMessage);

      // Try to send an error response
      try {
        const conversation = await client.conversations.getConversationById(
          message.conversationId,
        );
        if (conversation) {
          await conversation.send(
            "Sorry, I encountered an error creating the group.",
          );
        }
      } catch (sendError) {
        console.error(
          "Failed to send error message:",
          sendError instanceof Error ? sendError.message : String(sendError),
        );
      }
    }
  }
}

main().catch((error: unknown) => {
  console.error(
    "Unhandled error:",
    error instanceof Error ? error.message : String(error),
  );
  process.exit(1);
});

XMTP Helper Functions

import { getRandomValues } from "node:crypto";
import { IdentifierKind, type Signer } from "@xmtp/node-sdk";
import { fromString, toString } from "uint8arrays";
import { createWalletClient, http, toBytes } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";

interface User {
  key: `0x${string}`;
  account: ReturnType<typeof privateKeyToAccount>;
  wallet: ReturnType<typeof createWalletClient>;
}

export const createUser = (key: `0x${string}`): User => {
  const accountKey = key;
  const account = privateKeyToAccount(accountKey);
  return {
    key: accountKey,
    account,
    wallet: createWalletClient({
      account,
      chain: sepolia,
      transport: http(),
    }),
  };
};

export const createSigner = (key: `0x${string}`): Signer => {
  const user = createUser(key);
  return {
    type: "EOA",
    getIdentifier: () => ({
      identifierKind: IdentifierKind.Ethereum,
      identifier: user.account.address.toLowerCase(),
    }),
    signMessage: async (message: string) => {
      const signature = await user.wallet.signMessage({
        message,
        account: user.account,
      });
      return toBytes(signature);
    },
  };
};

/**
 * Generate a random encryption key
 * @returns The encryption key
 */
export const generateEncryptionKeyHex = () => {
  /* Generate a random encryption key */
  const uint8Array = getRandomValues(new Uint8Array(32));
  /* Convert the encryption key to a hex string */
  return toString(uint8Array, "hex");
};

/**
 * Get the encryption key from a hex string
 * @param hex - The hex string
 * @returns The encryption key
 */
export const getEncryptionKeyFromHex = (hex: string) => {
  /* Convert the hex string to an encryption key */
  return fromString(hex, "hex");
};

XMTP Packages Reference

// Client Class
declare class Client {
  constructor(client: Client$1, signer: Signer, codecs: ContentCodec[]);

XMTP Identifiers Reference

When working with XMTP, you'll encounter several types of identifiers:

Ethereum Addresses

  • Format: 0x followed by 40 hexadecimal characters
  • Example: 0xfb55CB623f2aB58Da17D8696501054a2ACeD1944
  • Usage: Identifies blockchain wallets associated with XMTP users

Private Key

  • Format: 0x followed by 64 hexadecimal characters
  • Example: 0x11567776b95bdbed513330f503741e19877bf7fe73e7957bf6f0ecf3e267fdb8
  • Usage: Used to create the signer for authenticating with XMTP

Encryption Key

  • Format: 64 hexadecimal characters (without "0x" prefix)
  • Example: 11973168e34839f9d31749ad77204359c5c39c404e1154eacb7f35a867ee47de
  • Usage: Used for encrypting the local database

Inbox ID

  • Format: 64 hexadecimal characters (without "0x" prefix)
  • Example: 1180478fde9f6dfd4559c25f99f1a3f1505e1ad36b9c3a4dd3d5afb68c419179
  • Usage: Primary identifier for XMTP conversations

Installation ID

  • Format: 64 hexadecimal characters (without "0x" prefix)
  • Example: a83166f3ab057f28d634cc04df5587356063dba11bf7d6bcc08b21a8802f4028
  • Usage: Identifies a specific XMTP client installation
  • Access via member.installationIds array on GroupMember objects

Example User Credentials Set

{
  "accountAddress": "0xfb55CB623f2aB58Da17D8696501054a2ACeD1944",
  "privateKey": "0x11567776b95bdbed513330f503741e19877bf7fe73e7957bf6f0ecf3e267fdb8",
  "encryptionKey": "11973168e34839f9d31749ad77204359c5c39c404e1154eacb7f35a867ee47de",
  "inboxId": "1180478fde9f6dfd4559c25f99f1a3f1505e1ad36b9c3a4dd3d5afb68c419179",
  "installationId": "a83166f3ab057f28d634cc04df5587356063dba11bf7d6bcc08b21a8802f4028"
}

Working with Members

All conversations, both Groups and DMs, have a members() method that returns an array of GroupMember objects:

// Get members from any conversation type (DM or Group)
const members = await conversation.members();

// Find a specific member
const member = members.find(
  (member) => member.inboxId.toLowerCase() === targetInboxId.toLowerCase(),
);

// Get member's Ethereum address
if (member) {
  const ethIdentifier = member.accountIdentifiers.find(
    (id) => id.identifierKind === IdentifierKind.Ethereum,
  );

  if (ethIdentifier) {
    const ethereumAddress = ethIdentifier.identifier;
    console.log(`Found Ethereum address: ${ethereumAddress}`);
  }

  // Get installation ID
  if (member.installationIds && member.installationIds.length > 0) {
    const installationId = member.installationIds[0];
    console.log(`Found installation ID: ${installationId}`);
  }
}

Working with Conversations

XMTP provides two main conversation types:

Direct Messages (DMs)

// Create a new DM
const dm = await client.conversations.newDm("inboxId123");

// Or create using an Ethereum address
const dmByAddress = await client.conversations.newDmWithIdentifier({
  identifier: "0x7c40611372d354799d138542e77243c284e460b2",
  identifierKind: IdentifierKind.Ethereum,
});

// Send a message
await dm.send("Hello!");

// Access peer's inbox ID
const peerInboxId = dm.peerInboxId;

Groups

// Create a new group
const group = await client.conversations.newGroup(["inboxId1", "inboxId2"], {
  groupName: "My Group",
  groupDescription: "Group description",
});

// Update group metadata
await group.updateName("New Group Name");
await group.updateDescription("Updated description");

// Manage members
await group.addMembers(["newMemberInboxId"]);
await group.removeMembers(["memberToRemoveInboxId"]);

// Manage permissions
await group.addAdmin("memberInboxId");
await group.addSuperAdmin("memberInboxId");

Group Creation Options

When creating a new group, use the correct options interface:

export interface CreateGroupOptions {
  permissions?: GroupPermissionsOptions;
  groupName?: string;
  groupImageUrlSquare?: string;
  groupDescription?: string;
  customPermissionPolicySet?: PermissionPolicySet;
  messageDisappearingSettings?: MessageDisappearingSettings;
}

Example usage:

// Create a group with some initial settings
const group = await client.conversations.newGroup([inboxId1, inboxId2], {
  groupName: "Project Discussion",
  groupDescription: "A group for our project collaboration",
  groupImageUrlSquare: "https://example.com/image.jpg",
});

// Update group settings later
await group.updateName("Updated Project Name");
await group.updateDescription("Our awesome project discussion");
await group.updateImageUrl("https://example.com/new-image.jpg");

Fetching Messages

There are two ways to retrieve messages from conversations:

1. Streaming Messages (Recommended for Agents)

Stream all messages to process them in real-time:

const stream = client.conversations.streamAllMessages();
for await (const message of await stream) {
  // Process each message as it arrives
  console.log(`Received message: ${message.content as string}`);
}

2. Polling Messages

Retrieve all messages at once from the local database:

// First sync the conversations from the network to update the local db
await client.conversations.sync();

// Then get all messages as an array
const messages = await conversation.messages();

Key Type References

// Client Class
declare class Client {
  constructor(client: Client$1, signer: Signer, codecs: ContentCodec[]);
  static create(
    signer: Signer,
    encryptionKey: Uint8Array,
    options?: ClientOptions,
  ): Promise<Client>;
  get inboxId(): string;
  get installationId(): string;
  get conversations(): Conversations;
  get preferences(): Preferences;
}

// Conversations Class
declare class Conversations {
  constructor(client: Client, conversations: Conversations$1);
  getConversationById(id: string): Promise<Dm | Group | undefined>;
  newGroupWithIdentifiers(
    identifiers: Identifier[],
    options?: CreateGroupOptions,
  ): Promise<Group>;
  newGroup(inboxIds: string[], options?: CreateGroupOptions): Promise<Group>;
  newDmWithIdentifier(
    identifier: Identifier,
    options?: CreateDmOptions,
  ): Promise<Dm>;
  newDm(inboxId: string, options?: CreateDmOptions): Promise<Dm>;
  sync(): Promise<void>;
  streamAllMessages(
    callback?: StreamCallback<DecodedMessage>,
  ): Promise<AsyncStream<DecodedMessage<any>>>;
}

// Conversation Base Class
declare class Conversation {
  client: Client;
  constructor(
    client: Client,
    conversation: Conversation$1,
    lastMessage?: Message | null,
  );
  get id(): string;
  send<T>(content: T, options?: SendOptions): Promise<string>;
  messages<T>(options?: PaginationOptions): Promise<Array<DecodedMessage<T>>>;
  members(): Promise<GroupMember[]>;
}

// Dm Class
declare class Dm extends Conversation {
  constructor(
    client: Client,
    conversation: Conversation$1,
    lastMessage?: Message | null,
  );
  get peerInboxId(): string;
}

// Group Class
declare class Group extends Conversation {
  constructor(
    client: Client,
    conversation: Conversation$1,
    lastMessage?: Message | null,
  );
  get name(): string;
  updateName(name: string): Promise<void>;
  get imageUrl(): string;
  updateImageUrl(imageUrl: string): Promise<void>;
  get description(): string;
  updateDescription(description: string): Promise<void>;
  get admins(): string[];
  get superAdmins(): string[];
  isAdmin(inboxId: string): boolean;
  isSuperAdmin(inboxId: string): boolean;
  addMembersByIdentifiers(identifiers: Identifier[]): Promise<void>;
  addMembers(inboxIds: string[]): Promise<void>;
  removeMembers(inboxIds: string[]): Promise<void>;
  addAdmin(inboxId: string): Promise<void>;
  removeAdmin(inboxId: string): Promise<void>;
  addSuperAdmin(inboxId: string): Promise<void>;
  removeSuperAdmin(inboxId: string): Promise<void>;
}

// GroupMember Class
declare class GroupMember {
  inboxId: string;
  accountIdentifiers: Array<Identifier>;
  installationIds: Array<string>;
  permissionLevel: PermissionLevel;
  consentState: ConsentState;
}

// DecodedMessage Class
declare class DecodedMessage<T = any> {
  content: T;
  contentType: ContentTypeId | undefined;
  conversationId: string;
  id: string;
  senderInboxId: string;
  sentAt: Date;
  constructor(client: Client, message: Message);
}

// Identifier Interface
export interface Identifier {
  identifier: string;
  identifierKind: IdentifierKind;
}

export declare const enum IdentifierKind {
  Ethereum = 0,
  Passkey = 1,
}

// CreateGroupOptions Interface
export interface CreateGroupOptions {
  groupName?: string;
  groupImageUrlSquare?: string;
  groupDescription?: string;
}

Common Usage Patterns

When working with these classes:

  1. Client

    • Created with Client.create(signer, encryptionKey, options)
    • Gateway to all XMTP functionality
    • Contains the conversations, contacts, and content types registries
  2. Conversations

    • Central interface for managing all conversations
    • Use sync() before accessing local conversation data
    • Use streamAllMessages() to listen for new messages in real-time
    • Create conversations with newDm(), newGroup(), etc.
  3. Dm

    • Access the peer using conversation.peerInboxId
    • Create new DMs with client.conversations.newDm(inboxId)
    • Send messages with dm.send(content)
  4. Group

    • Get members with await group.members(); (this works for DMs too)
    • Manage group metadata with updateName(), updateDescription(), etc.
    • Add/remove members with addMembers() and removeMembers()
    • Manage permissions with admin methods: addAdmin(), addSuperAdmin(), etc.
    • Check permissions with isAdmin() and isSuperAdmin()
  5. GroupMember

    • Use member.inboxId to identify members
    • Access Ethereum addresses through member.accountIdentifiers
    • Access installation IDs through member.installationIds
    • Check permission level with member.permissionLevel
    • Verify consent state with member.consentState

Other Notes

Handling local database

// Railway deployment support
let volumePath = process.env.RAILWAY_VOLUME_MOUNT_PATH ?? ".data/xmtp";
const dbPath = `${volumePath}/${signer.getAddress()}-${env}`;

// Create database directory if it doesn't exist
if (!fs.existsSync(dbPath)) {
  fs.mkdirSync(dbPath, { recursive: true });
}

Package.js

Example package.json for a project that uses XMTP:

{
  "name": "xmtp-agent",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx --watch index.ts",
    "gen:keys": "tsx scripts/generateKeys.ts",
    "start": "tsx index.ts"
  },
  "dependencies": {
    "@xmtp/node-sdk": "1.0.2"
    /* other dependencies */
  },
  "devDependencies": {
    "tsx": "^4.19.2",
    "typescript": "^5.7.3"
  },
  "packageManager": "[email protected]",
  "engines": {
    "node": ">=20"
  }
}

Web inbox

Interact with the XMTP network using xmtp.chat, the official web inbox for developers.

Work in local network

Dev and production networks are hosted by XMTP, while local network is hosted by yourself, so it's faster for development purposes.

    1. Install docker
    1. Start the XMTP service and database
./dev/up
    1. Change the .env file to use the local network
XMTP_ENV = local;