See what a risky command would change before it runs.

English | 中文
Karaka is a self-hosted server for adding persistent AI agents to an application. Connect a backend or browser frontend to named agents, stream their responses, and let them call selected application functions as tools.
Each named agent is an Agent Preset: a Cordis plugin composition that defines its prompt, tools, skills, and model behavior. Karaka runs as a separate process and keeps durable chat state; your application owns user authentication and business authorization.
Application backend Karaka process
@karaka-ai/sdk chat client -- HTTP / SSE --> named Agent Preset
@karaka-ai/sdk MCP tools <-- MCP -- selected application tools
Browser frontend Karaka process
@karaka-ai/agent/browser -- HTTP / SSE --> authenticated chat and events
Backend integrations use @karaka-ai/sdk for chat over HTTP/SSE and for hosting authenticated MCP tools on the application's existing HTTP server. The backend supplies trusted tenant and user identifiers. Karaka binds those identifiers to the chat and forwards them to tool callbacks, where the application enforces business authorization. Installing the SDK starts no process and opens no port.
Browser integrations use @karaka-ai/agent/browser with expiring credentials issued by the application backend. Each credential binds the application, tenant, and user identity; browser requests cannot choose another identity. See the browser client setup for authentication and server configuration.
<a id="run"></a>
Read the runtime implementation guide for composition, builds, and deployment limitations. The CLI and SDK are maintained in their own repositories.
Karaka builds on the open-source DeepSeek Harness developed by DeepSeek AI and retains its everything-is-a-plugin architecture powered by Cordis. Karaka has its own @karaka-ai/* packages and karaka CLI.
Karaka and its inherited Harness runtime are in developer preview and may make compatibility-breaking changes. Review the safety notice before running the project.
<a id="run-from-source"></a>
Start with the development guide and architecture documentation.
See CONTRIBUTING.md before proposing a change.
For agents, follow AGENTS.md.
Third-party dependencies and their licenses are disclosed in THIRD_PARTY_NOTICES.md.
hooks/blast-radius.mjs 128 lines1// Blast Radius, from "Getting started with Claude Code mods"
2// (https://claude.dev/blog/getting-started-with-claude-code-mods/, Anthropic, 2026-10-01).
3// The post publishes the tool.call hook below unchanged; the rest of the module
4// (classify, measure, the pane and band trees) is completed to the post's
5// description of the mod, which is marked where it starts.
6
7// Blast Radius: see what a risky command would change before it runs.
8
9// —— completed to the post's description (not in the published excerpt) ——
10
11const RISKS = [
12 { pattern: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/, kind: "delete", what: "delete files recursively" },
13 { pattern: /\bgit\s+reset\s+--hard\b/, kind: "reset", what: "discard uncommitted changes" },
14 { pattern: /\bgit\s+clean\b/, kind: "clean", what: "delete untracked files" },
15 { pattern: /\bgit\s+push\b[^\n]*\s(--force|-f)\b/, kind: "force-push", what: "rewrite a remote branch" },
16 { pattern: /\b(migrate|manage\.py\s+migrate|prisma\s+migrate|rails\s+db:migrate)\b/, kind: "migrate", what: "change the database schema" },
17];
18
19// What the hook is holding: one command at a time, until a button decides.
20let held = null;
21
22export function register(on) {
23 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
24 const risk = classify(String(e.command ?? ""));
25 if (risk === null) return next(e); // everything else runs as normal
26
27 const report = await measure($, risk, await $.session.cwd()); // git status, git clean -n, du, ...
28 held = { command: e.command, risk, report, decision: null };
29 const opened = await $.ui.open({ id: "blast-radius", title: "Blast Radius", focus: true });
30 if (!opened.isPlaced) held.where = "band"; // too narrow for a pane: draw above the prompt
31
32 while (held.decision === null && !next.signal.aborted) {
33 await $.process.run(["sleep", "0.25"]); // time inside $ calls doesn't count against the hook's time limit
34 }
35 if (held.decision === "proceed") return next(e); // let it run
36 return { deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${report.summary}.` };
37 });
38
39 // —— completed to the post's description (not in the published excerpt) ——
40
41 on("ui.render", { component: "Pane" }, ($, e, next) => {
42 if (e.requestId !== "blast-radius" || held === null || held.decision !== null) return next(e);
43 return report($, e);
44 });
45
46 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
47 if (held === null || held.decision !== null || held.where !== "band") return next(e);
48 return report($, e);
49 });
50}
51
52function classify(command) {
53 const risk = RISKS.find((r) => r.pattern.test(command));
54 return risk ? { kind: risk.kind, what: risk.what, command } : null;
55}
56
57async function measure($, risk, cwd) {
58 const lines = [];
59 let summary = `${risk.what} in ${cwd}`;
60 if (risk.kind === "delete") {
61 const target = risk.command.match(/\brm\s+-\S+\s+(\S+)/)?.[1];
62 const du = await run($, ["du", "-sh", target ?? "."]);
63 const size = du.stdout.trim().split(/\s+/)[0] ?? "?";
64 const count = await run($, ["find", target ?? ".", "-type", "f"]);
65 const files = count.stdout.split("\n").filter(Boolean).length;
66 summary = `delete ${files} file(s) (${size}) under ${target ?? cwd}`;
67 lines.push(...count.stdout.split("\n").filter(Boolean).slice(0, 9));
68 } else if (risk.kind === "reset") {
69 const status = await run($, ["git", "status", "--porcelain"]);
70 const changed = status.stdout.split("\n").filter(Boolean);
71 summary = `discard uncommitted changes to ${changed.length} file(s)`;
72 lines.push(...changed.slice(0, 9));
73 } else if (risk.kind === "clean") {
74 const dry = await run($, ["git", "clean", "-n"]);
75 const removed = dry.stdout.split("\n").filter(Boolean);
76 summary = `delete ${removed.length} untracked path(s)`;
77 lines.push(...removed.slice(0, 9));
78 } else if (risk.kind === "force-push") {
79 const ahead = await run($, ["git", "log", "--oneline", "HEAD..origin/main"]);
80 const commits = ahead.stdout.split("\n").filter(Boolean);
81 summary = `rewrite the remote branch, dropping ${commits.length} commit(s) not on this branch`;
82 lines.push(...commits.slice(0, 9));
83 } else if (risk.kind === "migrate") {
84 const pending = await run($, ["sh", "-c", "python manage.py showmigrations --plan 2>/dev/null | grep '\\[ \\]' || true"]);
85 const migrations = pending.stdout.split("\n").filter(Boolean);
86 summary = `apply ${migrations.length} pending migration(s)`;
87 lines.push(...migrations.slice(0, 9));
88 }
89 return { summary, lines };
90}
91
92// A dry run that fails (no git, no python, no such path) still lets the user decide.
93async function run($, argv) {
94 try {
95 return await $.process.run(argv);
96 } catch {
97 return { exitCode: 1, stdout: "", stderr: "" };
98 }
99}
100
101function decide(decision) {
102 if (held !== null) held.decision = decision;
103}
104
105function report($, e) {
106 const { Box, Text, Button } = $.ui.resolve(e);
107 const detail = held.report.lines.map((line) => Text({ dimColor: true, children: ` ${line}` }));
108 return Box({
109 flexDirection: "column",
110 border: true,
111 borderColor: "yellow",
112 paddingX: 1,
113 children: [
114 Text({ color: "yellow", bold: true, children: `Blast Radius: ${held.command}` }),
115 Text({ children: `Would ${held.report.summary}.` }),
116 ...detail,
117 Box({
118 flexDirection: "row",
119 gap: 2,
120 children: [
121 Button({ label: "Proceed", hotkey: "1", onPress: () => decide("proceed") }),
122 Button({ label: "Cancel", hotkey: "2", onPress: () => decide("cancel") }),
123 ],
124 }),
125 ],
126 });
127}
128