agentreflex
Guides

Writing a Reflex

Author a reflex as a plain module, or in TypeScript with defineReflex for types.

Scaffold

arx new no-force-push

This 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:

.reflex/config.json
{
  "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 with block. A top-level key in config.json is 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-separated

Other flags:

  • --agent <name> — pretend a specific agent is calling (default claude).
  • --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 from reflexes/<name> — see Contributing.

On this page