Writing a Reflex
Author a reflex as a plain module, or in TypeScript with defineReflex for types.
Scaffold
arx new no-force-pushThis writes .reflex/no-force-push.mjs and adds it to .reflex/config.json. Edit it and it fires on the next tool call — no rebuild.
A plain reflex
A reflex is a module with a default export: a name and an onToolCall handler that returns a decision.
export default {
name: "no-force-push",
onToolCall(ctx) {
if (ctx.tool !== "Bash") return { action: "pass" };
if (/git\s+push\b.*(--force|-f)\b/.test(ctx.command ?? ""))
return { action: "deny", reason: "no force-push — open a PR instead" };
return { action: "pass" };
},
};ctx carries tool, command, paths, cwd, and agent. Return { action: "pass" }, { action: "deny", reason }, or { action: "ask", reason }.
Typed, with defineReflex
For autocomplete and type-checking, author in TypeScript against @agentreflex/core and compile to .mjs. The helpers (deny, ask, pass) build the decisions for you, and parseCommand splits compound commands so cd x && git push --force can't slip past a check.
For path-based checks, pathMatchesGlob(filePath, globs, cwd) matches a path against glob patterns (** crosses directories, * stays within a segment, ? is one character) — and a path that resolves outside cwd never matches, so an allow-list can't be escaped with ../. Use it instead of hand-rolling glob-to-regex conversion.
import { defineReflex, deny, pass, parseCommand } from "@agentreflex/core";
export default defineReflex({
name: "no-force-push",
onToolCall(ctx) {
if (ctx.tool !== "Bash" || !ctx.command) return pass();
for (const c of parseCommand(ctx.command)) {
if (c.argv[0] === "git" && c.argv[1] === "push" && c.argv.includes("--force"))
return deny("no force-push — open a PR instead");
}
return pass();
},
});Configurable reflexes
A reflex can accept options so the same logic works with different settings. Whoever installs it sets them under with in .reflex/config.json, and the reflex reads them from ctx.options:
{
"reflexes": [
{ "source": "./my-reflex.mjs", "with": { "allow": ["src/**"] } }
]
}export default defineReflex({
name: "my-reflex",
onToolCall(ctx) {
const allow = (ctx.options?.allow as string[]) ?? [];
// ...use allow
return pass();
},
});Declare the options your reflex accepts in reflex.json under options, so they're documented and validated. Test a configured reflex with arx dev --with:
arx dev --reflex my-reflex --tool Edit --paths src/app.ts --with '{"allow":["src/**"]}'Options go under the entry's
withblock. A top-level key inconfig.jsonis not read.
Side effects with onToolResult
onToolResult runs after a tool and can't block — use it for snapshots, logging, or post-hoc checks.
export default defineReflex({
name: "audit-writes",
onToolResult(ctx) {
if (ctx.tool === "Write") log(ctx.paths);
},
});Test it
arx dev simulates a tool call against your reflexes and prints the verdict, so you can iterate without re-triggering the agent.
arx dev "git push --force origin main" # a Bash command (the default tool)Reflexes that key on files — not just shell commands — take --tool and --paths:
arx dev --tool Read --paths .env # simulate the agent reading .env
arx dev --tool Write --paths src/app.ts,.env # multiple paths, comma-separatedOther flags:
--agent <name>— pretend a specific agent is calling (defaultclaude).--event onToolResult— run the post-tool side effect instead of the pre-tool check.--file ./path/to/reflex.mjs— test one reflex file directly, ignoring.reflex/config.json.
By default dev runs the reflexes wired in .reflex/. See the full types in the spec.
Authoring an official reflex in the monorepo? Use
--reflex <name>to test it straight fromreflexes/<name>— see Contributing.