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
| Hook | Stage | If jes blocks |
|---|---|---|
before_agent_run | input | Returns outcome: "block" with the refusal as reason and message. |
before_tool_call | tool_call | Returns block: true and blockReason. The tool does not run. |
tool_result_persist | tool_result | Returns content set to the refusal. |
message_sending | output | Returns 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}`;
}