你可以用400行代码构建一个行为类似于OpenClaw的智能体。只需使用TypeScript、Anthropic SDK、Slack SDK和一个YAML解析库。无需框架,也无需复杂的抽象------只需在一个脚本中包含几个函数。
截至2026年2月19日,OpenClaw的代码库拥有超过50万行TypeScript代码。但其核心可以简化为一个非常短小精悍的智能体:它在Slack中响应,使用技能,跨对话记住事实,浏览计算机上的文件,执行命令,访问互联网,并自主行动------无需人工干预。就像OpenClaw一样。
在这篇文章中,你将了解它的内部工作原理。我将引导你从零开始构建一个类似OpenClaw的智能体,这样你就能更好地理解你可能已经在使用的工具------并将这些想法应用到你自己的智能体系统中。
我们将构建什么,不构建什么
我们不会重现完整的OpenClaw体验。这篇博客文章的目标是阐明OpenClaw背后的核心原则,而不是精确地重现它。我们不会构建Web界面、Telegram和WhatsApp集成、语音支持或其他生活质量功能。
然而,我们将构建一个功能齐全的智能体,它能够:
- 响应来自授权用户的Slack私信
- 使用计算机
- 访问互联网
- 跨对话保持记忆
- 使用智能体技能
- 学习用户的偏好
- 无需明确提示即可主动行动
这篇博客文章旨在让你能够跟着一起构建智能体。每个部分都会添加一个新功能,并在前一个功能的基础上进行构建,这样你就可以逐步看到和使用智能体的演变。
接收Slack消息
让我们从创建一个简单的脚本开始,它接收来自Slack的消息并进行回复。
初始化一个新的TypeScript项目。这里,我们将使用Bun:
bash
bun init
安装Slack SDK:
bash
bun add @slack/bolt
这是完整的逻辑。将其保存到一个名为index.ts的文件中。
typescript
import { App } from "@slack/bolt";
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
appToken: process.env.SLACK_APP_TOKEN,
socketMode: true,
});
app.event("message", async ({ event, client }) => {
console.log("Received message event at", new Date().toLocaleString());
if (event.subtype || event.channel_type !== "im") {
return;
}
await client.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
text: `Hey! Your Slack user id is \`${event.user}\` - you'll need it later.`,
});
});
console.log("Slack agent running");
app.start();
你需要创建一个Slack应用来运行它:
- 最简单的方法是访问这个链接:它预先填写了创建新应用所需的所有权限,以便与机器人交互。该链接是使用这个脚本生成的。
- 创建后,在"基本信息"页面上生成一个具有
connections:write范围的应用级令牌。Slack会要求你为其命名------任何名称都可以。该令牌将是SLACK_APP_TOKEN环境变量的值。 - 然后,通过访问"安装应用"页面并将应用安装到工作区来生成
SLACK_BOT_TOKEN环境变量。
现在使用以下命令运行机器人:
bash
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... bun run index.ts
在Slack中找到它。它会在搜索栏中显示为"picobot"。向它发送一条私信,它应该会回复。
作为LLM回复
现在让我们使用LLM生成回复。我们将使用Anthropic的SDK:
bash
bun add @anthropic-ai/sdk
LLM将能够通过工具调用发送Slack消息:
typescript
import Anthropic from "@anthropic-ai/sdk";
type ToolWithExecute = Anthropic.Tool & {
execute: (input: any) => Promise<any>;
};
function createTools(channel: string, threadTs: string): ToolWithExecute[] {
return [
{
name: "send_slack_message",
description:
"Send a message to the user in Slack. This is the only way to communicate with the user.",
input_schema: {
type: "object",
properties: {
text: {
type: "string",
description: "The message text (supports Slack mrkdwn formatting)",
},
},
required: ["text"],
},
execute: async (input: { text: string }) => {
await app.client.chat.postMessage({
channel,
thread_ts: threadTs,
text: input.text,
blocks: [
{
type: "markdown",
text: input.text,
},
],
});
return "Message sent.";
},
},
];
}
我们将创建一个函数,该函数调用Anthropic的API来生成响应,如果响应包含工具调用,则执行工具调用。
typescript
async function generateMessages(args: {
channel: string;
threadTs: string;
system: string;
messages: Anthropic.MessageParam[];
}): Promise<Anthropic.MessageParam[]> {
const { channel, threadTs, system, messages } = args;
const tools = createTools(channel, threadTs);
console.log("Generating messages for thread", threadTs);
const response = await anthropic.messages.create({
model: "claude-opus-4-6",
max_tokens: 8096,
system,
messages,
tools,
});
console.log(
`Response generated for thread ${threadTs}: ${response.usage.output_tokens} tokens`,
);
const toolsByName = new Map(tools.map((t) => [t.name, t]));
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== "tool_use") {
continue;
}
try {
console.log(`Agent used tool ${block.name}`);
const tool = toolsByName.get(block.name);
if (!tool) {
throw new Error(`tool "${block.name}" not found`);
}
const result = await tool.execute(block.input);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: typeof result === "string" ? result : JSON.stringify(result),
});
} catch (e: any) {
console.warn(`Agent tried to use tool ${block.name} but failed`, e);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: `Error: ${e.message}`,
is_error: true,
});
}
}
messages.push({ role: "assistant", content: response.content });
if (toolResults.length > 0) {
messages.push({
role: "user",
content: toolResults,
});
}
return messages;
}
最后,我们将在消息事件处理程序中调用generateMessages:
typescript
app.event("message", async ({ event }) => {
if (event.subtype || event.channel_type !== "im") {
return;
}
const threadTs = event.thread_ts ?? event.ts;
const channel = event.channel;
// Only allow authorized users to interact with the bot
if (event.user !== process.env.SLACK_USER_ID) {
await app.client.chat.postMessage({
channel,
thread_ts: threadTs,
text: `I'm sorry, I'm not authorized to respond to messages from you. Set the \`SLACK_USER_ID\` environment variable to \`${event.user}\` to allow me to respond to your messages.`,
});
return;
}
// Show a typing indicator to the user while we generate the response
// It'll be auto-cleared once the agent sends a Slack message
await app.client.assistant.threads.setStatus({
channel_id: channel,
thread_ts: threadTs,
status: "is typing...",
});
await generateMessages({
channel: event.channel,
threadTs,
system: "You are a helpful Slack assistant.",
messages: [
{
role: "user",
content: `User <@${event.user}> sent this message (timestamp: ${event.ts}) in Slack:\n\`\`\`\n${event.text}\n\`\`\`\n\nYou must respond using the \`send_slack_message\` tool.`,
},
],
});
});
这是目前为止的完整代码------你可以将其保存到index.ts中。
LLM现在可以响应来自授权用户的Slack消息。请记住在运行机器人之前设置SLACK_USER_ID环境变量。
bash
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... SLACK_USER_ID=U... bun run index.ts
跟踪对话
机器人会响应消息,但它不记得对话内容。
让我们改变这一点。我们将对话历史记录持久化到~/.picobot/threads/中的JSON文件中。每个文件都将以线程时间戳命名,并包含线程的消息。当收到新的Slack消息时,我们将加载线程并将新消息添加到其中。
typescript
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
const configDir = path.resolve(os.homedir(), ".picobot");
const threadsDir = path.resolve(configDir, "threads");
interface Thread {
threadTs: string;
channel: string;
messages: Anthropic.MessageParam[];
}
function saveThread(threadTs: string, thread: Thread): void {
fs.mkdirSync(threadsDir, { recursive: true });
return fs.writeFileSync(
path.resolve(threadsDir, `${threadTs}.json`),
JSON.stringify(thread, null, 2),
);
}
function loadThread(threadTs: string): Thread | undefined {
try {
return JSON.parse(
fs.readFileSync(path.resolve(threadsDir, `${threadTs}.json`), "utf-8"),
);
} catch (e) {
return undefined;
}
}
app.event("message", async ({ event }) => {
// ... existing code up to the status indicator ...
const thread: Thread = loadThread(threadTs) ?? {
threadTs,
channel,
messages: [],
};
const messages = await generateMessages({
channel: event.channel,
threadTs,
system: "You are a helpful Slack assistant.",
messages: [
...thread.messages,
{
role: "user",
content: `User <@${event.user}> sent this message (timestamp: ${event.ts}) in Slack:\n\`\`\`\n${event.text}\n\`\`\`\n\nYou must respond using the \`send_slack_message\` tool.`,
},
],
});
saveThread(threadTs, {
...thread,
messages,
});
});
这是目前为止的完整代码。
机器人现在会记住对话内容:
记忆压缩
现在,我们的机器人会记住对话内容,但它会记住所有内容。如果对话持续足够长的时间,它最终会超出LLM的上下文窗口。当这种情况发生时,LLM会开始"失忆",并且无法再引用旧的对话内容。
为了解决这个问题,我们将实现记忆压缩。当对话历史记录变得太长时,我们将要求LLM将其压缩成一个简短的摘要。然后,我们将用摘要替换旧的对话内容,从而为新的对话腾出空间。
typescript
// ... existing imports ...
import { dump } from "js-yaml";
// ... existing Thread interface ...
interface Thread {
threadTs: string;
channel: string;
messages: Anthropic.MessageParam[];
summary?: string;
}
// ... existing saveThread and loadThread functions ...
async function compactThread(thread: Thread): Promise<Thread> {
const response = await anthropic.messages.create({
model: "claude-opus-4-6",
max_tokens: 8096,
system: `You are a helpful Slack assistant. Your goal is to summarize the conversation so far.`, // Simplified system prompt for summarization
messages: [
...thread.messages,
{
role: "user",
content: `Please summarize the conversation so far in a concise way. The summary will be used to help me remember the context of the conversation. Do not include any information that is not relevant to the conversation.`, // Explicit instruction for summarization
},
],
});
const summary = response.content.map((block) => block.text).join("\n");
return {
...thread,
messages: [
{ role: "assistant", content: summary }, // Replace old messages with summary
],
summary,
};
}
app.event("message", async ({ event }) => {
// ... existing code up to the status indicator ...
let thread: Thread = loadThread(threadTs) ?? {
threadTs,
channel,
messages: [],
};
// Check if compaction is needed
const totalTokens = await anthropic.countTokens({
model: "claude-opus-4-6",
messages: thread.messages,
});
if (totalTokens > 4000) { // Arbitrary threshold for compaction
thread = await compactThread(thread);
}
const messages = await generateMessages({
channel: event.channel,
threadTs,
system: "You are a helpful Slack assistant.",
messages: [
...thread.messages,
{
role: "user",
content: `User <@${event.user}> sent this message (timestamp: ${event.ts}) in Slack:\n\`\`\`\n${event.text}\n\`\`\`\n\nYou must respond using the \`send_slack_message\` tool.`,
},
],
});
saveThread(threadTs, {
...thread,
messages,
});
});
这是目前为止的完整代码。
机器人现在会压缩对话历史记录,以避免超出LLM的上下文窗口。
技能
OpenClaw最强大的功能之一是它能够使用技能。技能是LLM可以调用的函数,以执行特定任务。例如,一个技能可以用来搜索网络,另一个技能可以用来生成图像。
我们将添加一个简单的技能,允许LLM搜索网络。我们将使用axios库来发出HTTP请求。
bash
bun add axios
我们将创建一个skills目录,并在其中添加一个web_search.ts文件:
typescript
// skills/web_search.ts
import axios from "axios";
export async function webSearch(query: string): Promise<string> {
const response = await axios.get("https://api.duckduckgo.com/html", {
params: {
q: query,
},
});
// Parse the HTML response to extract relevant information
// This is a simplified example, a real implementation would use a more robust HTML parser
return response.data.match(/<a class="result__a" href="(.*?)">/)?.[1] || "No results found.";
}
现在,我们将修改createTools函数以包含web_search技能:
typescript
// ... existing imports ...
import { webSearch } from "./skills/web_search";
function createTools(channel: string, threadTs: string): ToolWithExecute[] {
return [
// ... existing send_slack_message tool ...
{
name: "web_search",
description: "Search the web for a given query.",
input_schema: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query.",
},
},
required: ["query"],
},
execute: async (input: { query: string }) => {
return await webSearch(input.query);
},
},
];
}
现在,LLM将能够使用web_search技能来搜索网络。例如,如果你问它"OpenClaw是什么?",它可能会使用web_search技能来查找相关信息。
主动行动
OpenClaw最强大的功能之一是它能够主动行动,而无需明确提示。例如,它可以监视GitHub仓库的更新,并在有新提交时通知你。
我们将添加一个简单的机制,允许LLM主动行动。我们将使用一个cron作业来定期触发LLM,并让它决定是否需要采取行动。
typescript
// ... existing imports ...
import { CronJob } from "cron";
// ... existing app.event("message") handler ...
new CronJob(
"0 * * * * *", // Run every minute
async () => {
console.log("Cron job triggered");
// Load all threads
const threadFiles = fs.readdirSync(threadsDir);
for (const threadFile of threadFiles) {
const threadTs = threadFile.replace(".json", "");
let thread: Thread = loadThread(threadTs)!;
// Ask the LLM if it needs to take any proactive action
const messages = await generateMessages({
channel: thread.channel,
threadTs,
system: "You are a helpful Slack assistant. You are running as a cron job. You can take proactive actions without explicit prompting. If you have nothing to say, respond with NO_REPLY.",
messages: [
...thread.messages,
{
role: "user",
content: "Do you need to take any proactive actions? If not, respond with NO_REPLY.",
},
],
});
// If the LLM responded with NO_REPLY, do nothing
if (messages.length === thread.messages.length + 1 && messages[messages.length - 1].content === "NO_REPLY") {
continue;
}
saveThread(threadTs, {
...thread,
messages,
});
}
},
null, // onComplete
true, // start
"America/Los_Angeles" // timeZone
);
这是目前为止的完整代码。
机器人现在会主动行动,而无需明确提示。例如,它可以监视GitHub仓库的更新,并在有新提交时通知你。
结论
在这篇文章中,我们从零开始构建了一个类似OpenClaw的智能体,只用了不到400行代码。我们涵盖了以下功能:
- 响应Slack消息
- 作为LLM回复
- 跟踪对话
- 记忆压缩
- 使用技能
- 主动行动
我希望这篇博客文章能帮助你更好地理解OpenClaw的内部工作原理,并将这些想法应用到你自己的智能体系统中。
你可以在这里找到完整的代码。