feat: add messaging-agents bundled skill (#589)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-18 17:28:45 -08:00
committed by GitHub
parent 5cd31d8180
commit 7905260fc9
4 changed files with 865 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
---
name: messaging-agents
description: Send messages to other agents on your server. Use when you need to communicate with, query, or delegate tasks to another agent.
---
# Messaging Agents
This skill enables you to send messages to other agents on the same Letta server using the thread-safe conversations API.
## When to Use This Skill
- You need to ask another agent a question
- You want to query an agent that has specialized knowledge
- You need information that another agent has in their memory
- You want to coordinate with another agent on a task
## What the Target Agent Can and Cannot Do
**The target agent CANNOT:**
- Access your local environment (read/write files in your codebase)
- Execute shell commands on your machine
- Use your tools (Bash, Read, Write, Edit, etc.)
**The target agent CAN:**
- Use their own tools (whatever they have configured)
- Access their own memory blocks
- Make API calls if they have web/API tools
- Search the web if they have web search tools
- Respond with information from their knowledge/memory
**Important:** This skill is for *communication* with other agents, not *delegation* of local work. The target agent runs in their own environment and cannot interact with your codebase.
## Finding an Agent to Message
If you don't have a specific agent ID, use these skills to find one:
### By Name or Tags
Load the `finding-agents` skill to search for agents:
```bash
npx tsx <FINDING_AGENTS_SKILL_DIR>/scripts/find-agents.ts --query "agent-name"
npx tsx <FINDING_AGENTS_SKILL_DIR>/scripts/find-agents.ts --tags "origin:letta-code"
```
### By Topic They Discussed
Load the `searching-messages` skill to find which agent worked on something:
```bash
npx tsx <SEARCHING_MESSAGES_SKILL_DIR>/scripts/search-messages.ts --query "topic" --all-agents
```
Results include `agent_id` for each matching message.
## Script Usage
### Starting a New Conversation
```bash
npx tsx <SKILL_DIR>/scripts/start-conversation.ts --agent-id <id> --message "<text>"
```
**Arguments:**
| Arg | Required | Description |
|-----|----------|-------------|
| `--agent-id <id>` | Yes | Target agent ID to message |
| `--message <text>` | Yes | Message to send |
| `--timeout <ms>` | No | Max wait time in ms (default: 120000) |
**Example:**
```bash
npx tsx <SKILL_DIR>/scripts/start-conversation.ts \
--agent-id agent-abc123 \
--message "What do you know about the authentication system?"
```
**Response:**
```json
{
"conversation_id": "conversation-xyz789",
"response": "The authentication system uses JWT tokens...",
"agent_id": "agent-abc123",
"agent_name": "BackendExpert"
}
```
### Continuing a Conversation
```bash
npx tsx <SKILL_DIR>/scripts/continue-conversation.ts --conversation-id <id> --message "<text>"
```
**Arguments:**
| Arg | Required | Description |
|-----|----------|-------------|
| `--conversation-id <id>` | Yes | Existing conversation ID |
| `--message <text>` | Yes | Follow-up message to send |
| `--timeout <ms>` | No | Max wait time in ms (default: 120000) |
**Example:**
```bash
npx tsx <SKILL_DIR>/scripts/continue-conversation.ts \
--conversation-id conversation-xyz789 \
--message "Can you explain more about the token refresh flow?"
```
## Understanding the Response
- Scripts return only the **final assistant message** (not tool calls or reasoning)
- The target agent may use tools, think, and reason - but you only see their final response
- To see the full conversation transcript (including tool calls), use the `searching-messages` skill with `--agent-id` targeting the other agent
## How It Works
When you send a message, the target agent receives it with a system reminder:
```
<system-reminder>
This message is from "YourAgentName" (agent ID: agent-xxx), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code).
The sender will only see the final message you generate (not tool calls or reasoning).
If you need to share detailed information, include it in your response text.
</system-reminder>
```
This helps the target agent understand the context and format their response appropriately.
## Related Skills
- **finding-agents**: Find agents by name, tags, or fuzzy search
- **searching-messages**: Search past messages across agents, or view full conversation transcripts

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env npx tsx
/**
* Continue Conversation - Send a follow-up message to an existing conversation
*
* This script is standalone and can be run outside the CLI process.
* It reads auth from LETTA_API_KEY env var or ~/.letta/settings.json.
* It reads sender agent ID from LETTA_AGENT_ID env var.
*
* Usage:
* npx tsx continue-conversation.ts --conversation-id <id> --message "<text>"
*
* Options:
* --conversation-id <id> Existing conversation ID (required)
* --message <text> Message to send (required)
* --timeout <ms> Max wait time in ms (default: 120000)
*
* Output:
* JSON with conversation_id, response, agent_id, agent_name
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
// Use createRequire for @letta-ai/letta-client so NODE_PATH is respected
// (ES module imports don't respect NODE_PATH, but require does)
const require = createRequire(import.meta.url);
const Letta = require("@letta-ai/letta-client")
.default as typeof import("@letta-ai/letta-client").default;
type LettaClient = InstanceType<typeof Letta>;
interface ContinueConversationOptions {
conversationId: string;
message: string;
timeout?: number;
}
interface ContinueConversationResult {
conversation_id: string;
response: string;
agent_id: string;
agent_name: string;
}
/**
* Get API key from env var or settings file
*/
function getApiKey(): string {
if (process.env.LETTA_API_KEY) {
return process.env.LETTA_API_KEY;
}
const settingsPath = join(homedir(), ".letta", "settings.json");
try {
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
if (settings.env?.LETTA_API_KEY) {
return settings.env.LETTA_API_KEY;
}
} catch {
// Settings file doesn't exist or is invalid
}
throw new Error(
"No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
);
}
/**
* Get the sender agent ID from env var
*/
function getSenderAgentId(): string {
if (process.env.LETTA_AGENT_ID) {
return process.env.LETTA_AGENT_ID;
}
throw new Error(
"No LETTA_AGENT_ID found. This script should be run from within a Letta Code session.",
);
}
/**
* Create a Letta client with auth from env/settings
*/
function createClient(): LettaClient {
return new Letta({ apiKey: getApiKey() });
}
/**
* Build the system reminder prefix for the message
*/
function buildSystemReminder(
senderAgentName: string,
senderAgentId: string,
): string {
return `<system-reminder>
This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code).
The sender will only see the final message you generate (not tool calls or reasoning).
If you need to share detailed information, include it in your response text.
</system-reminder>
`;
}
/**
* Continue an existing conversation by sending a follow-up message
* @param client - Letta client instance
* @param options - Options including conversation ID and message
* @returns Conversation result with response and metadata
*/
export async function continueConversation(
client: LettaClient,
options: ContinueConversationOptions,
): Promise<ContinueConversationResult> {
const { conversationId, message } = options;
// 1. Fetch conversation to get agent_id and validate it exists
const conversation = await client.conversations.retrieve(conversationId);
// 2. Fetch target agent to get name
const targetAgent = await client.agents.retrieve(conversation.agent_id);
// 3. Fetch sender agent to get name for system reminder
const senderAgentId = getSenderAgentId();
const senderAgent = await client.agents.retrieve(senderAgentId);
// 4. Build message with system reminder prefix
const systemReminder = buildSystemReminder(senderAgent.name, senderAgentId);
const fullMessage = systemReminder + message;
// 5. Send message and consume the stream
// Note: conversations.messages.create always returns a Stream
const stream = await client.conversations.messages.create(conversationId, {
input: fullMessage,
});
// 6. Consume stream and extract final assistant message
let finalResponse = "";
for await (const chunk of stream) {
if (chunk.message_type === "assistant_message") {
// Content can be string or array of content parts
const content = chunk.content;
if (typeof content === "string") {
finalResponse += content;
} else if (Array.isArray(content)) {
for (const part of content) {
if (
typeof part === "object" &&
part !== null &&
"type" in part &&
part.type === "text" &&
"text" in part
) {
finalResponse += (part as { text: string }).text;
}
}
}
}
}
return {
conversation_id: conversationId,
response: finalResponse,
agent_id: targetAgent.id,
agent_name: targetAgent.name,
};
}
function parseArgs(args: string[]): ContinueConversationOptions {
const conversationIdIndex = args.indexOf("--conversation-id");
if (conversationIdIndex === -1 || conversationIdIndex + 1 >= args.length) {
throw new Error(
"Missing required argument: --conversation-id <conversation-id>",
);
}
const conversationId = args[conversationIdIndex + 1] as string;
const messageIndex = args.indexOf("--message");
if (messageIndex === -1 || messageIndex + 1 >= args.length) {
throw new Error("Missing required argument: --message <text>");
}
const message = args[messageIndex + 1] as string;
const options: ContinueConversationOptions = { conversationId, message };
const timeoutIndex = args.indexOf("--timeout");
if (timeoutIndex !== -1 && timeoutIndex + 1 < args.length) {
const timeout = Number.parseInt(args[timeoutIndex + 1] as string, 10);
if (!Number.isNaN(timeout)) {
options.timeout = timeout;
}
}
return options;
}
// CLI entry point - check if this file is being run directly
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
(async () => {
try {
const options = parseArgs(process.argv.slice(2));
const client = createClient();
const result = await continueConversation(client, options);
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error(`
Usage: npx tsx continue-conversation.ts --conversation-id <id> --message "<text>"
Options:
--conversation-id <id> Existing conversation ID (required)
--message <text> Message to send (required)
--timeout <ms> Max wait time in ms (default: 120000)
`);
process.exit(1);
}
})();
}

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env npx tsx
/**
* Start Conversation - Start a new conversation with an agent and send a message
*
* This script is standalone and can be run outside the CLI process.
* It reads auth from LETTA_API_KEY env var or ~/.letta/settings.json.
* It reads sender agent ID from LETTA_AGENT_ID env var.
*
* Usage:
* npx tsx start-conversation.ts --agent-id <id> --message "<text>"
*
* Options:
* --agent-id <id> Target agent ID to message (required)
* --message <text> Message to send (required)
* --timeout <ms> Max wait time in ms (default: 120000)
*
* Output:
* JSON with conversation_id, response, agent_id, agent_name
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join } from "node:path";
// Use createRequire for @letta-ai/letta-client so NODE_PATH is respected
// (ES module imports don't respect NODE_PATH, but require does)
const require = createRequire(import.meta.url);
const Letta = require("@letta-ai/letta-client")
.default as typeof import("@letta-ai/letta-client").default;
type LettaClient = InstanceType<typeof Letta>;
interface StartConversationOptions {
agentId: string;
message: string;
timeout?: number;
}
interface StartConversationResult {
conversation_id: string;
response: string;
agent_id: string;
agent_name: string;
}
/**
* Get API key from env var or settings file
*/
function getApiKey(): string {
if (process.env.LETTA_API_KEY) {
return process.env.LETTA_API_KEY;
}
const settingsPath = join(homedir(), ".letta", "settings.json");
try {
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
if (settings.env?.LETTA_API_KEY) {
return settings.env.LETTA_API_KEY;
}
} catch {
// Settings file doesn't exist or is invalid
}
throw new Error(
"No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
);
}
/**
* Get the sender agent ID from env var
*/
function getSenderAgentId(): string {
if (process.env.LETTA_AGENT_ID) {
return process.env.LETTA_AGENT_ID;
}
throw new Error(
"No LETTA_AGENT_ID found. This script should be run from within a Letta Code session.",
);
}
/**
* Create a Letta client with auth from env/settings
*/
function createClient(): LettaClient {
return new Letta({ apiKey: getApiKey() });
}
/**
* Build the system reminder prefix for the message
*/
function buildSystemReminder(
senderAgentName: string,
senderAgentId: string,
): string {
return `<system-reminder>
This message is from "${senderAgentName}" (agent ID: ${senderAgentId}), an agent currently running inside the Letta Code CLI (docs.letta.com/letta-code).
The sender will only see the final message you generate (not tool calls or reasoning).
If you need to share detailed information, include it in your response text.
</system-reminder>
`;
}
/**
* Start a new conversation with an agent and send a message
* @param client - Letta client instance
* @param options - Options including target agent ID and message
* @returns Conversation result with response and metadata
*/
export async function startConversation(
client: LettaClient,
options: StartConversationOptions,
): Promise<StartConversationResult> {
const { agentId, message } = options;
// 1. Fetch target agent to validate existence and get name
const targetAgent = await client.agents.retrieve(agentId);
// 2. Fetch sender agent to get name for system reminder
const senderAgentId = getSenderAgentId();
const senderAgent = await client.agents.retrieve(senderAgentId);
// 3. Create new conversation
const conversation = await client.conversations.create({
agent_id: agentId,
});
// 4. Build message with system reminder prefix
const systemReminder = buildSystemReminder(senderAgent.name, senderAgentId);
const fullMessage = systemReminder + message;
// 5. Send message and consume the stream
// Note: conversations.messages.create always returns a Stream
const stream = await client.conversations.messages.create(conversation.id, {
input: fullMessage,
});
// 6. Consume stream and extract final assistant message
let finalResponse = "";
for await (const chunk of stream) {
if (process.env.DEBUG) {
console.error("Chunk:", JSON.stringify(chunk, null, 2));
}
if (chunk.message_type === "assistant_message") {
// Content can be string or array of content parts
const content = chunk.content;
if (typeof content === "string") {
finalResponse += content;
} else if (Array.isArray(content)) {
for (const part of content) {
if (
typeof part === "object" &&
part !== null &&
"type" in part &&
part.type === "text" &&
"text" in part
) {
finalResponse += (part as { text: string }).text;
}
}
}
}
}
return {
conversation_id: conversation.id,
response: finalResponse,
agent_id: targetAgent.id,
agent_name: targetAgent.name,
};
}
function parseArgs(args: string[]): StartConversationOptions {
const agentIdIndex = args.indexOf("--agent-id");
if (agentIdIndex === -1 || agentIdIndex + 1 >= args.length) {
throw new Error("Missing required argument: --agent-id <agent-id>");
}
const agentId = args[agentIdIndex + 1] as string;
const messageIndex = args.indexOf("--message");
if (messageIndex === -1 || messageIndex + 1 >= args.length) {
throw new Error("Missing required argument: --message <text>");
}
const message = args[messageIndex + 1] as string;
const options: StartConversationOptions = { agentId, message };
const timeoutIndex = args.indexOf("--timeout");
if (timeoutIndex !== -1 && timeoutIndex + 1 < args.length) {
const timeout = Number.parseInt(args[timeoutIndex + 1] as string, 10);
if (!Number.isNaN(timeout)) {
options.timeout = timeout;
}
}
return options;
}
// CLI entry point - check if this file is being run directly
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
(async () => {
try {
const options = parseArgs(process.argv.slice(2));
const client = createClient();
const result = await startConversation(client, options);
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error),
);
console.error(`
Usage: npx tsx start-conversation.ts --agent-id <id> --message "<text>"
Options:
--agent-id <id> Target agent ID to message (required)
--message <text> Message to send (required)
--timeout <ms> Max wait time in ms (default: 120000)
`);
process.exit(1);
}
})();
}

View File

@@ -0,0 +1,293 @@
/**
* Tests for the bundled messaging-agents scripts
*/
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import type Letta from "@letta-ai/letta-client";
import { continueConversation } from "../../skills/builtin/messaging-agents/scripts/continue-conversation";
import { startConversation } from "../../skills/builtin/messaging-agents/scripts/start-conversation";
// Mock agent data
const mockSenderAgent = {
id: "agent-sender-123",
name: "SenderAgent",
};
const mockTargetAgent = {
id: "agent-target-456",
name: "TargetAgent",
};
const mockConversation = {
id: "conversation-789",
agent_id: mockTargetAgent.id,
};
// Helper to create a mock async iterator for streaming
function createMockStream(
chunks: Array<{ message_type: string; content?: string }>,
) {
return {
[Symbol.asyncIterator]: async function* () {
for (const chunk of chunks) {
yield chunk;
}
},
};
}
describe("start-conversation", () => {
const originalEnv = process.env.LETTA_AGENT_ID;
beforeEach(() => {
process.env.LETTA_AGENT_ID = mockSenderAgent.id;
});
afterEach(() => {
if (originalEnv) {
process.env.LETTA_AGENT_ID = originalEnv;
} else {
delete process.env.LETTA_AGENT_ID;
}
});
test("creates conversation and sends message with system reminder", async () => {
const mockRetrieve = mock((id: string) => {
if (id === mockSenderAgent.id) return Promise.resolve(mockSenderAgent);
if (id === mockTargetAgent.id) return Promise.resolve(mockTargetAgent);
throw new Error(`Unknown agent: ${id}`);
});
const mockCreate = mock(() => Promise.resolve(mockConversation));
const mockMessageCreate = mock(() =>
Promise.resolve(
createMockStream([
{ message_type: "reasoning_message", content: "thinking..." },
{ message_type: "assistant_message", content: "Hello there!" },
]),
),
);
const mockClient = {
agents: {
retrieve: mockRetrieve,
},
conversations: {
create: mockCreate,
messages: {
create: mockMessageCreate,
},
},
} as unknown as Letta;
const result = await startConversation(mockClient, {
agentId: mockTargetAgent.id,
message: "Hello!",
});
// Check that target agent was fetched
expect(mockRetrieve).toHaveBeenCalledWith(mockTargetAgent.id);
// Check that sender agent was fetched
expect(mockRetrieve).toHaveBeenCalledWith(mockSenderAgent.id);
// Check conversation was created
expect(mockCreate).toHaveBeenCalledWith({
agent_id: mockTargetAgent.id,
});
// Check message was sent with system reminder
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
input: expect.stringContaining("<system-reminder>"),
});
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
input: expect.stringContaining("Hello!"),
});
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
input: expect.stringContaining(mockSenderAgent.name),
});
// Check result
expect(result.conversation_id).toBe(mockConversation.id);
expect(result.response).toBe("Hello there!");
expect(result.agent_id).toBe(mockTargetAgent.id);
expect(result.agent_name).toBe(mockTargetAgent.name);
});
test("throws error when target agent not found", async () => {
const mockClient = {
agents: {
retrieve: mock(() => Promise.reject(new Error("Agent not found"))),
},
conversations: {
create: mock(),
messages: { create: mock() },
},
} as unknown as Letta;
await expect(
startConversation(mockClient, {
agentId: "nonexistent",
message: "Hello!",
}),
).rejects.toThrow("Agent not found");
});
test("throws error when LETTA_AGENT_ID not set", async () => {
delete process.env.LETTA_AGENT_ID;
const mockClient = {
agents: {
retrieve: mock(() => Promise.resolve(mockTargetAgent)),
},
conversations: {
create: mock(),
messages: { create: mock() },
},
} as unknown as Letta;
await expect(
startConversation(mockClient, {
agentId: mockTargetAgent.id,
message: "Hello!",
}),
).rejects.toThrow("LETTA_AGENT_ID");
});
});
describe("continue-conversation", () => {
const originalEnv = process.env.LETTA_AGENT_ID;
beforeEach(() => {
process.env.LETTA_AGENT_ID = mockSenderAgent.id;
});
afterEach(() => {
if (originalEnv) {
process.env.LETTA_AGENT_ID = originalEnv;
} else {
delete process.env.LETTA_AGENT_ID;
}
});
test("continues existing conversation with system reminder", async () => {
const mockAgentRetrieve = mock((id: string) => {
if (id === mockSenderAgent.id) return Promise.resolve(mockSenderAgent);
if (id === mockTargetAgent.id) return Promise.resolve(mockTargetAgent);
throw new Error(`Unknown agent: ${id}`);
});
const mockConversationRetrieve = mock(() =>
Promise.resolve(mockConversation),
);
const mockMessageCreate = mock(() =>
Promise.resolve(
createMockStream([
{ message_type: "assistant_message", content: "Follow-up response!" },
]),
),
);
const mockClient = {
agents: {
retrieve: mockAgentRetrieve,
},
conversations: {
retrieve: mockConversationRetrieve,
messages: {
create: mockMessageCreate,
},
},
} as unknown as Letta;
const result = await continueConversation(mockClient, {
conversationId: mockConversation.id,
message: "Follow-up question",
});
// Check conversation was fetched
expect(mockConversationRetrieve).toHaveBeenCalledWith(mockConversation.id);
// Check target agent was fetched
expect(mockAgentRetrieve).toHaveBeenCalledWith(mockTargetAgent.id);
// Check sender agent was fetched
expect(mockAgentRetrieve).toHaveBeenCalledWith(mockSenderAgent.id);
// Check message was sent with system reminder
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
input: expect.stringContaining("<system-reminder>"),
});
expect(mockMessageCreate).toHaveBeenCalledWith(mockConversation.id, {
input: expect.stringContaining("Follow-up question"),
});
// Check result
expect(result.conversation_id).toBe(mockConversation.id);
expect(result.response).toBe("Follow-up response!");
expect(result.agent_id).toBe(mockTargetAgent.id);
expect(result.agent_name).toBe(mockTargetAgent.name);
});
test("throws error when conversation not found", async () => {
const mockClient = {
agents: {
retrieve: mock(),
},
conversations: {
retrieve: mock(() =>
Promise.reject(new Error("Conversation not found")),
),
messages: { create: mock() },
},
} as unknown as Letta;
await expect(
continueConversation(mockClient, {
conversationId: "nonexistent",
message: "Hello!",
}),
).rejects.toThrow("Conversation not found");
});
test("handles empty response from agent", async () => {
const mockAgentRetrieve = mock((id: string) => {
if (id === mockSenderAgent.id) return Promise.resolve(mockSenderAgent);
if (id === mockTargetAgent.id) return Promise.resolve(mockTargetAgent);
throw new Error(`Unknown agent: ${id}`);
});
const mockConversationRetrieve = mock(() =>
Promise.resolve(mockConversation),
);
const mockMessageCreate = mock(() =>
Promise.resolve(
createMockStream([
{ message_type: "reasoning_message", content: "thinking..." },
// No assistant message - agent didn't respond with text
]),
),
);
const mockClient = {
agents: {
retrieve: mockAgentRetrieve,
},
conversations: {
retrieve: mockConversationRetrieve,
messages: {
create: mockMessageCreate,
},
},
} as unknown as Letta;
const result = await continueConversation(mockClient, {
conversationId: mockConversation.id,
message: "Hello?",
});
expect(result.response).toBe("");
});
});