Skip to main content

OpenClaw

Run uvx jes login first. See Setup.

Install​

Save the plugin and its runner in one directory, for example:

mkdir -p ~/jes-openclaw
uvx jes openclaw-settings > ~/jes-openclaw/openclaw-plugin.ts
uvx jes runner-settings > ~/jes-openclaw/jes-runner.ts

Link that directory as a local plugin with openclaw plugins install --link, then enable it. The plugin's default export is register. Restart the gateway whenever the plugin code changes.

The runner calls uvx jes@<version> hook, pinned to the release that printed it, so print both files again after you upgrade jes. If jes takes over 60 seconds, crashes, or prints no decision, the runner blocks with Blocked: jes did not answer.

before_agent_run is a conversation hook. For a plugin that isn't bundled with OpenClaw, set plugins.entries.<name>.hooks.allowConversationAccess to true, or that hook won't run. Check the OpenClaw plugin docs for the current rules.

What each hook does​

HookStageIf jes blocks
before_agent_runinputReturns outcome: "block" with the refusal as reason and message.
before_tool_calltool_callReturns block: true and blockReason. The tool does not run.
tool_result_persisttool_resultReturns content set to the refusal.
message_sendingoutputReturns content set to the refusal.

Source​

openclaw-plugin.ts
// Checks each step with jes through ./jes-runner.ts, which `jes runner-settings` prints.
// before_agent_run blocks the user prompt. before_tool_call blocks the call.
// tool_result_persist puts onward in content. message_sending rewrites the
// reply that is about to be delivered.
import { sessionOf, spawnJes, type JesRunner } from "./jes-runner.ts";

type HookContext = { sessionId?: string; sessionKey?: string };

export default function register(
api: {
on: (
name: string,
handler: (event: Record<string, unknown>, ctx: HookContext) => unknown,
) => void;
},
run: JesRunner = spawnJes,
) {
api.on("before_agent_run", (event, ctx) => {
const prompt = typeof event.prompt === "string" ? event.prompt : "";
const checked = run({
stage: "input",
text: prompt,
session_id: sessionOf(ctx.sessionId ?? ctx.sessionKey, "openclaw"),
});
if (!checked.ok) {
return { outcome: "block", reason: checked.onward, message: checked.onward };
}
return undefined;
});

api.on("before_tool_call", (event, ctx) => {
const tool = typeof event.toolName === "string" ? event.toolName : "";
const checked = run({
stage: "tool_call",
tool,
arguments: event.params ?? {},
session_id: sessionOf(ctx.sessionId ?? ctx.sessionKey, "openclaw"),
});
if (!checked.ok) {
return { block: true, blockReason: checked.onward };
}
return undefined;
});

api.on("tool_result_persist", (event, ctx) => {
const tool = typeof event.toolName === "string" ? event.toolName : "tool";
const content =
typeof event.content === "string" ? event.content : JSON.stringify(event.content ?? "");
const checked = run({
stage: "tool_result",
tool,
text: content,
session_id: sessionOf(ctx.sessionId ?? ctx.sessionKey, "openclaw"),
});
if (!checked.ok) {
return { content: checked.onward };
}
return undefined;
});

api.on("message_sending", (event, ctx) => {
const text =
typeof event.content === "string" ? event.content : JSON.stringify(event.content ?? "");
const checked = run({
stage: "output",
text,
session_id: sessionOf(ctx.sessionId ?? ctx.sessionKey, "openclaw"),
});
if (!checked.ok) {
return { content: checked.onward };
}
return undefined;
});
}
jes-runner.ts
// Calls `uvx jes@0.0.1 hook` on stdin. uvx ships with uv, so there is no separate jes install.
// `jes runner-settings` prints this file with the version filled in. Print it again after upgrading jes.
import { spawnSync } from "node:child_process";

export type JesDecision = { ok: boolean; onward: string; decision: string };

export type JesRunner = (body: Record<string, unknown>) => JesDecision;

// The agent waits for every check, so a check that hangs blocks after this long.
const TIMEOUT_MS = 60_000;
const UNANSWERED: JesDecision = {
ok: false,
onward: "Blocked: jes did not answer.",
decision: "block",
};

export function spawnJes(body: Record<string, unknown>): JesDecision {
const run = spawnSync("uvx", ["jes@0.0.1", "hook"], {
input: JSON.stringify(body),
encoding: "utf8",
timeout: TIMEOUT_MS,
maxBuffer: 16 * 1024 * 1024,
});
return parseDecision(run.stdout);
}

// A jes that crashed, timed out, or is misconfigured prints nothing usable. That blocks.
export function parseDecision(stdout: string | null | undefined): JesDecision {
const text = (stdout ?? "").trim();
if (!text) {
return UNANSWERED;
}
try {
const parsed = JSON.parse(text) as Partial<JesDecision>;
if (typeof parsed.ok !== "boolean" || typeof parsed.onward !== "string") {
return UNANSWERED;
}
return { ok: parsed.ok, onward: parsed.onward, decision: parsed.ok ? "allow" : "block" };
} catch {
return UNANSWERED;
}
}

export function sessionOf(value: string | undefined, fallback: string): string {
const cleaned = (value ?? fallback).replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 200);
return /^[A-Za-z0-9]/.test(cleaned) ? cleaned : `s${cleaned}`;
}