Writing a custom adapter¶
An adapter connects one coding agent to AgentLint. This page is for contributors adding support for a new agent to the AgentLint package. If you only want to run AgentLint from your own tool without changing AgentLint, the generic adapter or the MCP server is usually enough.
AgentLint 2.9.0 ships 10 adapters: claude, cursor, codex, gemini,
continue, kimi, grok, openai, mcp and generic. They live in
src/agentlint/adapters/. Read one of the hook-based ones (gemini.py is
short) before starting.
What an adapter does¶
An adapter is a subclass of AgentAdapter (src/agentlint/adapters/base.py).
It is responsible for:
- Translating the agent's native event names to
AgentEvent. - Mapping the agent's tool names to
NormalizedTool. - Resolving the project directory and a session key.
- Supplying the output formatter whose JSON and exit code the agent understands.
- Installing and removing AgentLint's hook entries in the agent's config file.
All abstract members:
| Member | Signature | Used by |
|---|---|---|
platform_name |
property, -> str |
check (sets RuleContext.agent_platform, coverage heartbeat, diagnostic bundle) |
formatter |
property, -> OutputFormatter |
check (output text and exit code) |
resolve_project_dir() |
-> str |
MCP and OpenAI adapters, programmatic use |
resolve_session_key() |
-> str |
MCP server, programmatic use |
translate_event(native_event) |
-> AgentEvent, raise ValueError if unknown |
check --event |
normalize_tool_name(native_tool) |
-> str (a NormalizedTool value) |
check (translates native tool calls before rules run) |
build_rule_context(event, raw_payload, project_dir, session_state) |
-> RuleContext |
MCP/OpenAI adapters, programmatic use |
install_hooks(project_dir, scope="project", dry_run=False, cmd=None) |
-> None |
agentlint setup <name> |
uninstall_hooks(project_dir, scope="project") |
-> None |
agentlint uninstall <name> |
How agentlint check uses an adapter¶
Hook-based agents run agentlint check --adapter <name> --event <NativeEvent>
and send JSON on stdin. In 2.9.0 the check command:
- calls
translate_event()on the--eventvalue. If that raisesValueError, it falls back toAgentEvent.from_string(), so generic values such aspre_tool_usealso work; - builds the
RuleContextitself from the payload keystool_name,tool_input,prompt,last_assistant_message(orsubagent_output),notification_type,compact_source,agent_transcript_path,agent_type,agent_idandtool_response. It does not callbuild_rule_context(); - uses
platform_nameforagent_platform, the coverage heartbeat and the diagnostic bundle; - passes the violations to
formatter.format()and exits withformatter.exit_code().
Session state comes from agentlint.session, which reads
AGENTLINT_SESSION_ID, CLAUDE_SESSION_ID, CURSOR_SESSION_ID or
OPENAI_SESSION_ID and falls back to the parent PID. Your agent can set
AGENTLINT_SESSION_ID in the hook environment.
Tool names matter. Built-in rules check the canonical tool names Bash,
Write and Edit and the input keys command, file_path, content,
old_string and new_string. Before evaluating, check translates native
tool calls through your adapter's normalize_tool_name(): tools that map to
shell, file_write or file_edit become Bash, Write and Edit, and
common native input keys (path, cmd, old_str, ...) are copied to the
canonical ones (agentlint/adapters/normalize.py). So map every shell and file
tool your agent has, and check real payloads with
agentlint check --diagnostic-bundle (see diagnostics.md).
Example adapter¶
This adapter supports a hypothetical agent called Acme. Acme reads hooks from
.acme/hooks.json (project) or ~/.acme/hooks.json (user), sends
beforeTool / afterTool / sessionEnd events, and accepts the same JSON
shape as PlainJsonFormatter.
# src/agentlint/adapters/acme.py
"""Acme agent adapter for AgentLint."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import click
from agentlint.adapters._utils import (
is_agentlint_nested_entry,
read_json_config,
resolve_command,
write_json_config,
)
from agentlint.adapters.base import AgentAdapter
from agentlint.formats.plain_json import PlainJsonFormatter
from agentlint.models import AgentEvent, NormalizedTool, RuleContext, to_hook_event
_ACME_EVENT_MAP: dict[str, AgentEvent] = {
"beforeTool": AgentEvent.PRE_TOOL_USE,
"afterTool": AgentEvent.POST_TOOL_USE,
"sessionEnd": AgentEvent.STOP,
}
_ACME_TOOL_MAP: dict[str, NormalizedTool] = {
"Bash": NormalizedTool.SHELL,
"Write": NormalizedTool.FILE_WRITE,
"Edit": NormalizedTool.FILE_EDIT,
"Read": NormalizedTool.FILE_READ,
}
def _hooks_path(scope: str, project_dir: str | None = None) -> Path:
"""Return the Acme hook file. Signature must be (scope, project_dir)."""
if scope == "user":
return Path.home() / ".acme" / "hooks.json"
base = Path(project_dir) if project_dir else Path.cwd()
return base / ".acme" / "hooks.json"
def _build_hooks(cmd: str) -> dict[str, list[dict]]:
"""Hook entries keyed by native event name."""
hooks: dict[str, list[dict]] = {}
for event, timeout in (("beforeTool", 5), ("afterTool", 10), ("sessionEnd", 30)):
hooks[event] = [
{
"matcher": "Bash|Write|Edit",
"hooks": [
{
"type": "command",
"command": f"{cmd} check --event {event} --adapter acme",
"timeout": timeout,
}
],
}
]
return hooks
class AcmeAdapter(AgentAdapter):
"""AgentAdapter implementation for the Acme agent."""
@property
def platform_name(self) -> str:
return "acme"
@property
def formatter(self) -> PlainJsonFormatter:
return PlainJsonFormatter()
def resolve_project_dir(self) -> str:
return (
os.environ.get("AGENTLINT_PROJECT_DIR")
or os.environ.get("ACME_PROJECT_DIR")
or os.getcwd()
)
def resolve_session_key(self) -> str:
return (
os.environ.get("AGENTLINT_SESSION_ID")
or os.environ.get("ACME_SESSION_ID")
or f"pid-{os.getppid()}"
)
def translate_event(self, native_event: str) -> AgentEvent:
try:
return _ACME_EVENT_MAP[native_event]
except KeyError as exc:
raise ValueError(f"Unknown Acme event: {native_event}") from exc
def normalize_tool_name(self, native_tool: str) -> str:
return _ACME_TOOL_MAP.get(native_tool, NormalizedTool.UNKNOWN).value
def build_rule_context(
self,
event: AgentEvent,
raw_payload: dict[str, Any],
project_dir: str,
session_state: dict,
) -> RuleContext:
return RuleContext(
event=to_hook_event(event),
tool_name=raw_payload.get("tool_name", ""),
tool_input=raw_payload.get("tool_input", {}),
project_dir=project_dir,
config={},
session_state=session_state,
prompt=raw_payload.get("prompt"),
agent_platform="acme",
)
def install_hooks(
self,
project_dir: str,
scope: str = "project",
dry_run: bool = False,
cmd: str | None = None,
) -> None:
cmd = cmd or resolve_command()
path = _hooks_path(scope, project_dir)
existing = read_json_config(path)
if existing is None:
raise click.ClickException(f"{path} is not valid JSON; fix it and retry.")
settings = dict(existing)
hooks = dict(settings.get("hooks", {}))
for event, ours in _build_hooks(cmd).items():
# Drop our previous entries so repeated installs are idempotent.
kept = [e for e in hooks.get(event, []) if not is_agentlint_nested_entry(e)]
hooks[event] = kept + ours
settings["hooks"] = hooks
if dry_run:
click.echo(f"\nDry run - would write to {path}:")
click.echo(json.dumps(settings, indent=2))
return
write_json_config(path, settings)
def uninstall_hooks(self, project_dir: str, scope: str = "project") -> None:
path = _hooks_path(scope, project_dir)
existing = read_json_config(path)
if not existing:
return # Missing (empty dict) or corrupted (None): leave it alone.
settings = dict(existing)
hooks = {
event: [e for e in entries if not is_agentlint_nested_entry(e)]
for event, entries in settings.get("hooks", {}).items()
}
hooks = {event: entries for event, entries in hooks.items() if entries}
if hooks:
settings["hooks"] = hooks
else:
settings.pop("hooks", None)
write_json_config(path, settings)
Helpers in agentlint.adapters._utils:
resolve_command()returns the absolute command used to runagentlint.read_json_config(path)returns{}for a missing file andNonefor a corrupted one.write_json_config(path, data)creates parent directories and writes indented JSON.is_agentlint_nested_entry(entry)/is_agentlint_flat_entry(entry)detect entries AgentLint installed earlier, for{"matcher": ..., "hooks": [{"command": ...}]}and{"command": ...}shapes.
If the agent needs a different response format, add a formatter in
src/agentlint/formats/ that subclasses OutputFormatter
(src/agentlint/formats/base.py) and implements format() and exit_code().
Both receive either an AgentEvent or the raw --event string, so handle both.
# src/agentlint/formats/acme_hooks.py
"""Acme hook response formatter."""
from __future__ import annotations
import json
from agentlint.formats.base import OutputFormatter
from agentlint.models import AgentEvent, Severity, Violation
class AcmeHookFormatter(OutputFormatter):
"""Deny on ERROR, otherwise return advisory text."""
def exit_code(self, violations: list[Violation], event: AgentEvent | str = "") -> int:
return 2 if any(v.severity == Severity.ERROR for v in violations) else 0
def format(self, violations: list[Violation], event: AgentEvent | str = "") -> str | None:
if not violations:
return None
blocked = any(v.severity == Severity.ERROR for v in violations)
return json.dumps(
{
"decision": "deny" if blocked else "allow",
"message": "\n".join(self._format_violation_lines(violations)),
}
)
Registering the adapter¶
There is no plugin mechanism for adapters; they are registered in several places in the package. Do all of these.
1. Adapter registry¶
src/agentlint/adapters/__init__.py: add the dotted path to
_ADAPTER_REGISTRY and the name to the get_adapter() docstring.
agentlint setup and agentlint uninstall use get_adapter().
_ADAPTER_REGISTRY: dict[str, str] = {
# ... existing entries ...
"acme": "agentlint.adapters.acme.AcmeAdapter",
}
2. Supported platform names¶
src/agentlint/cli.py: add the name to _SUPPORTED_ADAPTERS. setup and
uninstall reject names that are not in this tuple.
_SUPPORTED_ADAPTERS = (
# ... existing names ...
"acme",
)
3. check --adapter mapping¶
src/agentlint/cli.py: import the class at the top of the module and add it to
the _ADAPTERS dict inside _resolve_adapter(). If the agent sets a
recognisable environment variable, add an auto-detection branch before the
Claude default.
from agentlint.adapters.acme import AcmeAdapter
# inside _resolve_adapter():
_ADAPTERS: dict[str, Any] = {
# ... existing entries ...
"acme": AcmeAdapter,
}
# ...
if os.environ.get("ACME_SESSION_ID") or os.environ.get("ACME_PROJECT_DIR"):
return AcmeAdapter()
Installed hooks should always pass --adapter <name> explicitly; auto-detection
is a fallback.
4. Hook platform lists (hook-based adapters only)¶
Skip this step for adapters that are not driven by a hook file (like openai,
mcp and generic).
In src/agentlint/cli.py:
- add the name to
_HOOK_PLATFORMS(used byonboard,setup-agent,statusanddoctor); - add the project hook file to
_platform_hook_file(); - add the agent's environment markers to
env_markersin_detected_agent_platforms().
In src/agentlint/coverage.py:
- add the name to
HOOK_PLATFORMS(keep it identical tocli._HOOK_PLATFORMS); - add
(module, function)to_PATH_FUNCS. The function must accept(scope, project_dir)and return aPath; coverage calls it for both"project"and"user"scope.
# src/agentlint/coverage.py
_PATH_FUNCS = {
# ... existing entries ...
"acme": ("agentlint.adapters.acme", "_hooks_path"),
}
Coverage reads the hook file without executing it. It reports the events that
mention AgentLint under a top-level "hooks" object in a JSON file, and
classifies the install as installed, wrapper, stale, custom or missing. The
heartbeat (record_heartbeat(), called by check) proves the hook really ran,
keyed by platform_name.
5. Tool-name map¶
src/agentlint/core/models.py: if the agent's tool names differ from Claude
Code's, add a map to _PLATFORM_TOOL_MAPS so RuleContext.normalized_tool
works for agent_platform == "acme". Without an entry it falls back to the
Claude map.
6. Tests¶
Add tests/test_acme_adapter.py, following tests/test_gemini_adapter.py or
tests/test_cursor_adapter.py. Cover at least:
- every native event in
translate_event(), and that an unknown event raisesValueError; normalize_tool_name()for each mapped tool and an unknown one;build_rule_context()fields;_hooks_path()for project and user scope (monkeypatchPath.home);- install into an empty directory, repeated install (idempotent), preserving
third-party hooks,
dry_runwriting nothing; - uninstall removing only AgentLint entries, and leaving a corrupted file alone;
- the formatter's output and exit code for ERROR, WARNING and no violations;
- an end-to-end
checkrun throughclick.testing.CliRunnerwith--adapter acmeand a real payload.
# tests/test_acme_adapter.py
from __future__ import annotations
import json
import pytest
from click.testing import CliRunner
from agentlint.adapters.acme import AcmeAdapter, _hooks_path
from agentlint.cli import main
from agentlint.models import AgentEvent, NormalizedTool
def test_translates_before_tool() -> None:
assert AcmeAdapter().translate_event("beforeTool") == AgentEvent.PRE_TOOL_USE
def test_unknown_event_raises() -> None:
with pytest.raises(ValueError, match="Unknown Acme event"):
AcmeAdapter().translate_event("nope")
def test_normalizes_bash() -> None:
assert AcmeAdapter().normalize_tool_name("Bash") == NormalizedTool.SHELL.value
def test_install_is_idempotent(tmp_path) -> None:
adapter = AcmeAdapter()
adapter.install_hooks(str(tmp_path), cmd="agentlint")
adapter.install_hooks(str(tmp_path), cmd="agentlint")
data = json.loads(_hooks_path("project", str(tmp_path)).read_text())
assert len(data["hooks"]["beforeTool"]) == 1
def test_check_blocks_force_push(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("AGENTLINT_SESSION_ID", "test-acme")
monkeypatch.setenv("HOME", str(tmp_path))
payload = {"tool_name": "Bash", "tool_input": {"command": "git push --force origin main"}}
result = CliRunner().invoke(
main,
["check", "--adapter", "acme", "--event", "beforeTool", "--project-dir", str(tmp_path)],
input=json.dumps(payload),
)
assert result.exit_code == 1 # PlainJsonFormatter exits 1 on ERROR
assert json.loads(result.output)["blocked"] is True
Also run the full suite: tests/test_cli.py iterates over _HOOK_PLATFORMS.
uv run pytest -q
uv run ruff format --check .
uv run ruff check .
7. Docs¶
- Add
docs/agents/acme.mdwith install, verify and uninstall steps, and link it from docs/README.md. - Add the agent to the supported-agents list in the top-level
README.md. - Add a
CHANGELOG.mdentry.
See CONTRIBUTING.md for the rest of the PR checklist and architecture.md for where adapters sit in the system.