Bắt đầu xây dựng các bản mở rộng mod cho Claude Code

Hướng dẫn chi tiết cách tạo bản mở rộng mod cho Claude Code bằng JavaScript/TypeScript để tùy biến giao diện terminal và giám sát cửa sổ ngữ cảnh.

Xây dựng bản mở rộng (mod) Claude Code đầu tiên từ một thư mục trống, sau đó khám phá thêm những khả năng khác mà giao diện lập trình ứng dụng (API) mang lại.

Một bản mở rộng (mod) là một tệp JavaScript hoặc TypeScript nhỏ chạy trực tiếp bên trong phiên làm việc Claude Code. Bản mở rộng có thể theo dõi diễn biến hoạt động, thay đổi hành vi xử lý của Claude Code hoặc tự vẽ giao diện người dùng (UI) riêng trong môi trường dòng lệnh (terminal) hoặc ứng dụng máy tính để bàn (desktop app). Bạn không cần phải học toàn bộ API mới có thể thử nghiệm: chỉ cần khởi chạy claude, mô tả bản mở rộng bạn muốn tạo, chấp nhận tính năng tự động tải lại trực tiếp (hot reload) khi được hỏi, và bản mở rộng sẽ xuất hiện ngay sau khi lượt tương tác kết thúc.

Claude Code vốn đã hỗ trợ tùy biến nhiều hành vi: cài đặt cấu hình, quy tắc phân quyền, các lệnh gạch chéo (slash commands), kỹ năng (skills) và dòng trạng thái (status line). Các bản mở rộng (mods) tiến xa hơn thế: chúng có thể ghi đè hoặc thay thế hoàn toàn hành động của Claude Code, đồng thời kết xuất giao diện tùy biến. Dưới nền tảng vận hành, các bản mở rộng thực chất là những điểm móc (hooks) được đóng gói bên trong tiện ích bổ sung (plugins), và mỗi điểm móc đều ghi nhận mọi sự kiện diễn ra trong phiên theo thời gian thực.

Cơ chế này biến các bản mở rộng thành giải pháp điều chỉnh Claude Code phù hợp với luồng làm việc cá nhân. Bạn có thể chèn thêm bảng thông số thường xuyên theo dõi, đặt rào chắn kiểm soát trước các câu lệnh tiềm ẩn rủi ro, hoặc xây dựng màn hình đánh giá mã nguồn theo đúng thói quen đối chiếu thay đổi của mình.

Bài hướng dẫn này sẽ từng bước dựng một bản mở rộng từ thư mục trống có tên Token Weather – công cụ dự báo trực tiếp mức sử dụng cửa sổ ngữ cảnh (context window) được vẽ ngay phía trên dấu nhắc lệnh (prompt) với dung lượng khoảng 80 dòng mã. Tiếp đó, chúng ta sẽ khảo sát hai bản mở rộng phức tạp hơn là Blast Radius và Replay Theater để thấy được toàn bộ tiềm năng của API. Toàn bộ mã nguồn hoàn chỉnh của cả ba dự án được lưu trữ tại kho lưu trữ anthropics/claude-code-playground.

Yêu cầu phiên bản Claude Code 2.1.287 trở lên. Các bản mở rộng được bật mặc định nên không cần cấu hình thêm. API có thể thay đổi giữa các bản phát hành. Mỗi khi Claude Code tải một bản mở rộng, hệ thống sẽ ghi các khai báo kiểu dữ liệu dành riêng cho bản dựng của bạn vào thư mục .claude-plugin/types/ của bản mở rộng, và đây chính là tài liệu chuẩn xác nhất cho phiên bản đang dùng.

Một bản mở rộng là một tiện ích bổ sung (plugin) của Claude Code mà toàn bộ hành vi được định nghĩa trong một mô-đun (module) JavaScript hoặc TypeScript:

  • Thư mục là một plugin tiêu chuẩn, có tệp cấu hình định danh .claude-plugin/plugin.json.
  • Tệp hooks/hooks.json chỉ định một mô-đun trong mục modules.
  • Mô-đun xuất ra hàm register(on, options). Bên trong hàm này, on(event, matcher?, hook) sẽ bổ sung một điểm móc (hook).

Mỗi điểm móc đều có cấu trúc nhất quán:

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
  // $    the mods API: ui, session, state, store, fs, process, clock, http, tool, command, model, ...
  // e    this event's input, as plain data
  // next passes e to the other plugins and then to Claude Code's own behavior
  return next(e);
});

Các điểm móc tạo thành một chuỗi xử lý tương tự như phần mềm trung gian (middleware). Điểm móc của bạn chạy trước, gọi next(e) để chuyển giao sự kiện cho plugin kế tiếp, và cuối cùng Claude Code sẽ thực thi hành vi mặc định ban đầu. Một điểm móc có thể quan sát, sửa đổi dữ liệu hoặc chặn hoàn toàn chuỗi xử lý.

Danh sách sự kiện hỗ trợ bao gồm lệnh gọi công cụ (tool calls), nội dung dấu nhắc khi gửi, thời điểm bắt đầu và kết thúc lượt phản hồi, quá trình khởi tạo và đóng phiên làm việc, lệnh gạch chéo, cùng sự kiện ui.render đại diện cho từng phần giao diện khi được vẽ. Mô-đun chạy trong môi trường hộp cát (sandbox) độc lập, không có DOM và không có môi trường Node trực tiếp, do đó mọi tương tác với bên ngoài đều phải thông qua đối tượng $.

Điểm khác biệt so với điểm móc cài đặt (settings hooks): Điểm móc cài đặt thực thi một lệnh shell riêng cho mỗi sự kiện và truyền dữ liệu JSON qua stdin/stdout. Trong khi đó, bản mở rộng chỉ tải một lần duy nhất và duy trì trạng thái xuyên suốt phiên. Bản mở rộng có thể lưu giữ trạng thái, kết xuất giao diện cập nhật liên tục theo sự kiện và gọi ngược lại vào Claude Code: mở ngăn phụ (pane), chạy tiến trình, đăng ký lệnh gạch chéo mới hoặc khai báo công cụ để mô hình gọi.

Bản thân Claude Code cũng tự ứng dụng cơ chế này. Một số tính năng nội bộ của Claude Code được xây dựng dưới dạng bản mở rộng, bao gồm tính năng hỗ trợ AGENTS.md và ngăn hiển thị khác biệt /diff cạnh khung hội thoại. Mã nguồn kèm kiểm thử tự động của chúng được công khai trong kho lưu trữ anthropics/claude-code tại thư mục mods/ để cộng đồng tiện tham khảo cách đội ngũ kỹ thuật triển khai.

XÂY DỰNG BẢN MỞ RỘNG ĐẦU TIÊN: TOKEN WEATHER

Token Weather đọc dữ liệu dung lượng cửa sổ ngữ cảnh sau mỗi lượt phản hồi và hiển thị một dòng thông tin ngay phía trên dấu nhắc: biểu tượng thời tiết, tỷ lệ phần trăm, số lượng token đã dùng trên tổng dung lượng, biểu đồ thu nhỏ các lượt gần nhất và lượng token phát sinh thêm từ lượt trước.

Trong một phiên làm việc thực tế, mỗi lượt phản hồi đọc thêm nhiều tệp, dải thông tin sẽ biến chuyển trạng thái từ ☀ Clear (Quang đãng) sang ☂ Showers (Mưa rào) rồi đến ☇ Storm (Bão dông):

Cách nhanh nhất: để Claude tự xây dựng

Bạn hoàn toàn có thể bỏ qua sáu bước thủ công bên dưới. Claude Code đã được huấn luyện cách viết các bản mở rộng, do đó bạn chỉ cần mô tả tính năng mong muốn và để mô hình tự triển khai. Khởi tạo một phiên với claude và dán nội dung dấu nhắc sau:

Make me a Claude Code mod called token-weather: a live forecast of my context window, shown in the band above the prompt.

What it should show, on one line:
- A weather icon and word for how full the context window is: under 25% ☀ Clear (yellow), 25–49% ☁ Cloudy (cyan), 50–74% ☂ Showers (blue), 75–89% ☇ Storm (magenta), 90% and up ↯ Compact soon (red).
- The percentage used, then the tokens used out of the window, like "134.4k / 200k".
- A small chart of the last 12 turns, drawn with ▁▂▃▄▅▆▇█.
- How much the last turn added, like "▲ +98.3k last turn".

It should update after every turn.

Claude sẽ hỏi xác nhận kích hoạt tính năng nạp lại trực tiếp (hot reloading) cho phiên làm việc. Hãy chấp thuận yêu cầu này, và thanh thông tin sẽ hiện ra phía trên dấu nhắc ngay khi lượt xử lý của Claude kết thúc. Kể từ lúc đó, mỗi chỉnh sửa sẽ tự nạp lại ngay lập tức, cho phép bạn tiếp tục yêu cầu tinh chỉnh (“đặt ngưỡng Storm từ 70%”, “thêm chi phí ước tính bằng USD ở cuối”) và quan sát giao diện cập nhật ngay. Bản mở rộng này chỉ tồn tại trong phiên hiện tại và thư mục tạm sẽ được dọn dẹp sau đó; vì vậy nếu muốn lưu lại, hãy sao chép thư mục ra ngoài và cài đặt như một plugin bình thường (Bước 6).

Lưu ý rằng câu nhắc trên chỉ thuần túy mô tả những gì bạn muốn thấy trên màn hình mà không cần biết chi tiết API. Tài liệu hướng dẫn tích hợp của Claude Code đã bao quát toàn bộ kỹ thuật: vị trí lưu trạng thái để không bị mất khi tải lại, cách kiểm tra plugin bằng lệnh claude plugin validate và danh sách sự kiện cần móc nối. Thay đổi các dòng mô tả hiển thị, bạn sẽ có ngay bản mở rộng theo ý thích riêng.

Nếu bạn muốn nắm rõ cấu trúc thành phần trước hoặc muốn tự kiểm tra đoạn mã Claude đã viết, hãy tiếp tục theo dõi các bước sau.

Bước 1: Khởi tạo cấu trúc thư mục

Kiểm tra phiên bản Claude Code hiện tại:

claude --version   # 2.1.287 or later

Tạo cấu trúc thư mục như sau:

token-weather/
├── .claude-plugin/
│   ├── plugin.json
│   └── types/            (written by Claude Code when it loads the mod)
├── hooks/
│   ├── hooks.json
│   └── token-weather.mjs
├── types/
│   └── index.d.ts        (added in step 3)
└── tests/
    └── token-weather.test.ts   (added in step 5)

Tệp .claude-plugin/plugin.json là bản khai báo tiện ích tiêu chuẩn:

{
  "name": "token-weather",
  "version": "0.1.0",
  "description": "A live forecast of the context window, drawn above the prompt.",
  "author": { "name": "You" }
}

Tệp hooks/hooks.json trỏ đến mô-đun thực thi. Một bản mở rộng chỉ chứa đúng một mô-đun:

{
  "modules": ["./token-weather.mjs"]
}

Bước 2: Hiển thị giao diện cơ bản

Dải không gian nằm ngay phía trên dấu nhắc là thành phần mang tên AbovePrompt. Mặc định Claude Code không vẽ gì tại đây, biến nó thành vị trí lý tưởng để thử nghiệm. Hãy móc nối sự kiện ui.render của thành phần này và trả về một cây phần tử:

// hooks/token-weather.mjs
export function register(on) {
  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
    const { Box, Text } = $.ui.resolve(e);
    return Box({
      paddingX: 1,
      children: [Text({ color: "yellow", bold: true, children: "☀  Clear skies" })],
    });
  });
}

Các phần tử giao diện không phải là biến toàn cục. Phương thức $.ui.resolve(e) sẽ trả về các hàm khởi tạo tương ứng với bề mặt đang hiển thị, do mỗi bề mặt giao diện trong Claude Code hỗ trợ tập hợp phần tử khác nhau đôi chút. Cú pháp JSX cũng được hỗ trợ với h làm factory.

Khởi chạy phiên làm việc kèm tiện ích vừa tạo:

claude --plugin-dir ./token-weather

Dòng chữ “☀ Clear skies” sẽ xuất hiện phía trên dấu nhắc. Hãy giữ nguyên phiên này. Thư mục được tự động giám sát thay đổi, do đó mỗi lần lưu tệp sẽ tự động nạp lại mô-đun mà không cần khởi động lại tiến trình. Vòng phản hồi tức thì này chính là điểm thú vị khi viết các bản mở rộng.

Mẹo: Khi đã quen với cấu trúc, bạn có thể mô tả ý tưởng bản mở rộng tiếp theo cho Claude theo cách viết tắt. Hệ thống sẽ tự tạo plugin trong thư mục và kích hoạt chế độ nạp lại trực tiếp trong chính phiên làm việc đó.

Bước 3: Đọc dữ liệu thực tế và lưu vào $.state

Hàm $.session.usage() trả về các thông số tương tự như dòng trạng thái. Trong đó, context.tokens là số lượng token đầu vào được dùng để phản hồi lượt gần nhất, context.window là kích thước tối đa của cửa sổ ngữ cảnh, và context.percent là tỷ lệ phần trăm giữa hai đại lượng này. Lệnh gọi này hoàn toàn không tốn chi phí: hệ thống chỉ gửi yêu cầu đếm token nếu bạn yêu cầu phân tích chi tiết.

Thu thập thông số khi phiên khởi động và sau mỗi lượt hoàn tất:

on("session.start", async ($, e, next) => {
  const result = await next(e);
  await takeReading($);
  return result;
});

on("turn.complete", async ($, e, next) => {
  const result = await next(e);
  if (!e.agentId) {
    await takeReading($); // main-loop turns only, not subagents
  }
  return result;
});

Cả hai điểm móc đều gọi next(e) trước rồi mới ghi nhận dữ liệu mà không làm thay đổi luồng xử lý chính.

Nơi lưu trữ lịch sử số liệu: Khai báo biến cấp mô-đun dạng let readings = [] dường như là giải pháp hiển nhiên, nhưng khi nạp lại trực tiếp, toàn bộ mô-đun sẽ được tải mới: hàm register chạy lại, sự kiện session.start kích hoạt lại và các biến mô-đun bị thiết lập lại từ đầu. Thay vào đó, hãy lưu lịch sử vào $.state. Đối tượng này lưu giữ các giá trị có định danh tại tiến trình chủ trong suốt phiên làm việc và không bị mất đi khi nạp lại mã.

// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };

async function takeReading($) {
  const { context } = await $.session.usage();
  if (!context?.window) return;
  const tokens = context.tokens ?? 0;
  const percent = context.percent ?? Math.round((tokens / context.window) * 100);
  const { value: history = [] } = await $.state.get(readings);
  await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}

Giá trị trạng thái cần được khai báo trong hợp đồng kiểu dữ liệu của plugin – một tệp .d.ts nhỏ được chỉ định trong manifest. Thêm tệp types/index.d.ts:

export type TokenWeatherReading = { tokens: number; window: number; percent: number };

declare module "claude-code" {
  interface PluginState {
    "token-weather": { readings: TokenWeatherReading[] };
  }
}

Sau đó bổ sung "types": "./types/index.d.ts" vào plugin.json. Nếu bỏ qua bước này, lệnh claude plugin validate sẽ báo lỗi và chỉ rõ cách khắc phục: token-weather.readings is not declared: the manifest's types contract must name it in interface PluginState { … }.

Bù lại, bạn nhận được cơ chế tự động vẽ lại giao diện mà không tốn công cấu hình. Bất kỳ lệnh $.state.get nào được gọi trong khi điểm móc kết xuất đang chạy sẽ tự động đăng ký theo dõi bề mặt đó, do đó mỗi lệnh $.state.set sau này sẽ tự kích hoạt vẽ lại dải thông tin. Bạn hoàn toàn không cần gọi $.ui.invalidate thủ công.

Bước 4: Kết xuất thông tin dự báo

Dưới đây là toàn bộ mã nguồn của mô-đun:

// Token Weather: a live forecast of the context window, above the prompt.

const HISTORY = 12;
const BARS = "▁▂▃▄▅▆▇█";
const FORECAST = [
  { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
  { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
  { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
  { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
  { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
];

// Held by the host, so the history survives a hot reload of this file.
const readings = { plugin: "token-weather", key: "readings" };

export function register(on) {
  on("session.start", async ($, e, next) => {
    const result = await next(e);
    await takeReading($);
    return result;
  });

  on("turn.complete", async ($, e, next) => {
    const result = await next(e);
    if (!e.agentId) {
      await takeReading($); // main-loop turns only, not subagents
    }
    return result;
  });

  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
    const { value: history = [] } = await $.state.get(readings);
    if (e.props.hasSurvey || history.length === 0) {
      return next(e);
    }
    const { Box, Text } = $.ui.resolve(e);
    return band(Box, Text, history, e.props.bodyColumns);
  });
}

async function takeReading($) {
  const { context } = await $.session.usage();
  if (!context?.window) return;
  const tokens = context.tokens ?? 0;
  const percent = context.percent ?? Math.round((tokens / context.window) * 100);
  const { value: history = [] } = await $.state.get(readings);
  await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
}

function band(Box, Text, history, columns) {
  const now = history[history.length - 1];
  const f = FORECAST.find((b) => now.percent < b.upTo);
  const parts = [
    Text({ color: f.color, bold: true, children: `${f.icon}  ${f.word}` }),
    Text({ children: `  ${now.percent}% of context` }),
    Text({ dimColor: true, children: `  ${short(now.tokens)} / ${short(now.window)}` }),
  ];
  if (columns >= 60) {
    parts.push(Text({ dimColor: true, children: "   last turns " }));
    parts.push(Text({ color: f.color, children: sparkline(history) }));
    if (history.length > 1) {
      parts.push(Text({ dimColor: true, children: trend(history) }));
    }
  }
  return Box({ flexDirection: "row", paddingX: 1, children: parts });
}

function sparkline(history) {
  const top = Math.max(...history.map((r) => r.tokens), 1);
  return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
}

function trend(history) {
  const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;
  if (delta === 0) return "  steady";
  return delta > 0 ? `  ▲ +${short(delta)} last turn` : `  ▼ ${short(-delta)} last turn`;
}

function short(n) {
  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
  return String(n);
}

Ba chi tiết quan trọng cần lưu ý khi xây dựng bản mở rộng:

  • Các thuộc tính thành phần nằm tại e.props. Biến hasSurvey cho biết bảng khảo sát đang cần dùng dải không gian này, do đó điểm móc nên nhường quyền hiển thị bằng lệnh next(e). Biến bodyColumns là chiều rộng thực tế của dải hiển thị, vốn sẽ hẹp hơn chiều rộng terminal khi có một ngăn phụ mở cạnh bản ghi cuộc hội thoại; hãy căn chỉnh kích thước cây giao diện theo thông số này. Chỉ có e.component, e.surface, e.requestId và e.viewport nằm ở cấp cao nhất của đối tượng e.
  • Bỏ qua kết xuất khi không có nội dung. Việc trả về next(e) sẽ nhượng lại quyền hiển thị cho Claude Code và các bản mở rộng khác.
  • Sử dụng các ký tự đơn độ rộng (single-width symbols), không dùng biểu tượng cảm xúc (emoji). Các ký tự ☀ ☁ ☂ ☇ ↯ hiển thị thẳng hàng trên mọi phông chữ dòng lệnh.

Lưu tệp lại và phiên làm việc đang chạy sẽ tự động tiếp nhận mã mới. Sau một vài lượt đọc các tệp lớn, dải hiển thị sẽ chuyển dần từ Clear sang Showers rồi đến Storm như trong phần mô tả đầu mục.

Bước 5: Kiểm tra tính hợp lệ và thử nghiệm

Lệnh claude plugin validate đọc manifest và mã nguồn mô-đun theo cách thức tương tự như Claude Code trong môi trường thực tế, đồng thời báo cáo danh sách điểm móc và các lệnh gọi:

$ claude plugin validate ./token-weather
  > types ./types/index.d.ts declares state: token-weather.readings
  > ./token-weather.mjs hooks: session.start, turn.complete, ui.render{component=AbovePrompt}
  > ./token-weather.mjs calls: $.session.usage (via takeReading), $.state.get, $.state.set (via takeReading), $.ui.resolve
  > ./token-weather.mjs state writes: token-weather.readings
  > ./token-weather.mjs state reads: token-weather.readings
√ Validation passed

Lệnh claude plugin test chạy các tệp *.test.ts của plugin trực tiếp trên môi trường thực thi chuẩn của Claude Code. Các điểm móc được đăng ký trong bài kiểm thử bằng on sẽ chạy sau bản mở rộng trong chuỗi xử lý và giả lập dữ liệu trả về từ Claude Code, giúp bạn kiểm soát chính xác kết quả trả về của $.session.usage():

// tests/token-weather.test.ts
import { describe, expect, test } from "claude-code/testing";

describe("token-weather", () => {
  test("the band follows the context window", async ($, on) => {
    // Hooks registered here run after the mod and stub what Claude Code would answer.
    let tokens = 36_100;
    on("session.start", ($, e) => ({ cwd: e.cwd }));
    on("session.usage", () => ({
      value: { startedAt: 0, rateLimits: [], context: { tokens, window: 200_000, percent: Math.round(tokens / 2_000) } },
    }));
    on("turn.complete", () => ({ text: "" }));

    await $.session.start({ surface: "terminal", isInteractive: true, cwd: "/work" } as any);
    const ui = await $.ui.mount({
      plugin: "token-weather",
      surface: "terminal",
      component: "AbovePrompt",
      props: { hasSurvey: false, isWorking: false, maxRows: 10, bodyColumns: 120 },
    } as any);
    expect(await ui.find({ type: "Text", text: /Clear/ })).toBeDefined();

    tokens = 134_400;
    await $.turn.complete({ reason: "answer", answer: "ok", durationMs: 1 } as any);
    expect(await ui.find({ type: "Text", text: /Showers/ })).toBeDefined();
    expect(await ui.find({ type: "Text", text: /67% of context/ })).toBeDefined();
    expect(await ui.find({ type: "Text", text: /▲ \+98\.3k last turn/ })).toBeDefined();
    await ui.unmount();
  });
});
$ claude plugin test ./token-weather
(pass) token-weather > the band follows the context window
 1 pass
 0 fail

Bài kiểm thử này cũng đồng thời kiểm tra cơ chế vẽ lại tự động ở bước 3. Dải thông tin tự cập nhật sau sự kiện turn.complete mà bản mở rộng không cần gửi bất kỳ yêu cầu vẽ lại thủ công nào.

Bước 6: Chia sẻ bản mở rộng

Bản mở rộng thực chất là một tiện ích bổ sung (plugin) nên việc phân phối diễn ra hoàn toàn tương tự. Bạn có thể đưa tiện ích lên một chợ tiện ích (marketplace) – đơn giản chỉ là một thư mục chứa tệp .claude-plugin/marketplace.json:

{
  "name": "my-mods",
  "owner": { "name": "You" },
  "plugins": [{ "name": "token-weather", "source": "./token-weather" }]
}
claude plugin marketplace add ./my-mods
claude plugin install token-weather@my-mods --scope user

Để so sánh phiên bản của bạn với bản hoàn thiện, hãy tham khảo kho lưu trữ chính thức từ Anthropic.

Nguồn: Claude Blog (Anthropic)

Chính sách hỗ trợ doanh nghiệp
LIÊN HỆ TƯ VẤN CÁC DỊCH VỤ AI
Hỗ trợ tư vấn, đào tạo và chuyển giao AI cho cá nhân, doanh nghiệp và tổ chức.
Chat Zalo Chat Zalo
Gọi ngay Chat