self-modifying hook plugin for opencode. gives LLM agents the ability to modify their own hooks and prompts (with test validation), discover and invoke custom tools, and evolve their behavior at runtime.
for a comprehensive production example se persona
add to your opencode.jsonc:
set OPENCODE_EVOLVE_WORKSPACE to your workspace directory (default: ~/workspace).
~/workspace/
├── config/
│ ├── evolve.jsonc # evolve settings
│ └── runtime.json # runtime state (auto-managed)
├── hooks/
│ └── evolve.py # hook script
├── prompts/ # prompt templates
└── tests/ # hook validation tests
config/evolve.jsonc — all fields optional:
{
"hook": "hooks/evolve.py", // hook script path (workspace-relative)
"heartbeat_ms": 1800000, // heartbeat interval (30 min)
"hook_timeout": 30000, // subprocess timeout (30s)
"heartbeat_title": "heartbeat", // heartbeat session title
"heartbeat_agent": "evolve", // agent ID for heartbeat prompts
"heartbeat_cleanup": "none", // "none" | "new" | "archive" | "compact"
"heartbeat_cleanup_count": null, // cleanup after N heartbeats (null = disabled)
"heartbeat_cleanup_tokens": null,// cleanup after N total tokens (null = disabled)
"test_script": null // path to test script for hook validation
}heartbeat_cleanup defines what happens when a threshold is reached:
none: thresholds are ignored; heartbeat accumulates indefinitely in one session.new: starts a new heartbeat session; the old one remains active.archive: starts a new heartbeat session and archives the old one (hides it in WebUI).compact: triggers server-side compaction on the current session.
heartbeat_cleanup_count and heartbeat_cleanup_tokens are evaluated independently; the first threshold reached triggers the action.
heartbeat_agent must match a configured agent in your opencode.jsonc. for example:
{
"default_agent": "evolve",
"agent": {},
"plugin": ["opencode-evolve"]
}with a corresponding agent file at agents/evolve.md.
the plugin calls your hook script as a subprocess:
$WORKSPACE/hooks/<hook> <hook_name>
input: JSON on stdin with at minimum {"hook": "<name>", ...context}.
output: JSONL on stdout. each line is a JSON object. lines with {"log": "..."} are printed to the debug log. all other lines are merged into the final result.
stderr: forwarded to the debug log.
exit code: 0 = success, non-zero = failure (triggers recover hook unless the failing hook is observational).
called once at plugin init. return tool definitions.
input:
{"hook": "discover"}output:
{"tools": [{"name": "my_tool", "description": "...", "parameters": {"arg": "description"}}]}called on each new session to generate the system prompt. return {"system": [...]} to manage the session, or {} to skip. the result is cached per-session (system prompt is frozen after first call).
input:
{"hook": "mutate_request", "session": {"id": "..."}, "history": [...]}output:
{"system": ["system prompt text..."]}called after each LLM response. observational — failure does not trigger recover.
input:
{"hook": "observe_message", "session": {"id": "...", "agent": "..."}, "thinking": "...", "calls": [...], "answer": "..."}output:
{"modified": ["file.md"], "notify": [{"type": "some_change", "files": ["file.md"]}], "actions": [...]}called when the LLM gives a final response with no tool calls. return {"continue": "message"} to force the session to keep going.
input:
{"hook": "idle", "session": {"id": "...", "agent": "..."}, "answer": "..."}output:
{}or:
{"continue": "follow-up prompt text"}called on the heartbeat timer interval. return a system prompt and user message to send to the heartbeat session.
input:
{"hook": "heartbeat", "sessions": [], "history": [...]}output:
{"system": ["..."], "user": "heartbeat prompt text"}called when opencode compacts a session. return a custom compaction prompt.
input:
{"hook": "compacting", "session": {"id": "..."}, "history": [...]}output:
{"prompt": "compaction prompt text..."}called to format pending notifications before injecting them into a session. observational — failure does not trigger recover.
input:
{"hook": "format_notification", "session": {"id": "..."}, "notifications": [...]}output:
{"message": "[update] modified: FOO.md. re-read if needed."}called when another hook fails (except observational hooks). return emergency system prompt and user message.
input:
{"hook": "recover", "error": "...", "failed_hook": "..."}output:
{"system": ["recovery prompt"], "user": "recovery instructions"}called when a discovered tool is invoked.
input:
{"hook": "execute_tool", "tool": "my_tool", "args": {"arg": "value"}}output:
{"result": "tool output", "modified": ["file.md"], "notify": [...]}called before/after any opencode tool execution. observational.
input (before):
{"hook": "tool_before", "session": {"id": "..."}, "tool": "tool_name", "callID": "...", "args": {}}input (after):
{"hook": "tool_after", "session": {"id": "..."}, "tool": "tool_name", "callID": "...", "title": "...", "output": "..."}the plugin provides builtin tools that let agents modify their own behavior at runtime:
- hook editing —
hook_read,hook_write,hook_patchlet the agent rewrite its own hook script. writes are validated against the configuredtest_scriptbefore installation. - prompt editing —
prompt_list,prompt_read,prompt_write,prompt_patchlet the agent modify its own prompt templates. - tool discovery — custom tools defined by the hook's
discoverresponse are automatically registered with opencode.
tools are defined by the hook's discover response. each tool gets a prefixed name derived from the hook config path's filename stem. for example, with the default hooks/evolve.py, a tool named my_tool becomes evolve_my_tool.
the plugin provides these tools regardless of what the hook returns. they use the same prefix:
<prefix>_datetime— get the current date and time in UTC<prefix>_heartbeat_time— get the last heartbeat runtime in UTC<prefix>_prompt_list— list prompt files in the workspace<prefix>_prompt_read— read a prompt file<prefix>_prompt_write— write a prompt file<prefix>_prompt_patch— patch a prompt file (find-and-replace)<prefix>_hook_validate— validate a hook script without installing<prefix>_hook_read— read the current hook script<prefix>_hook_write— write a new hook (validated before install)<prefix>_hook_patch— patch the hook (validated before install)
the workspace is auto-initialized as a git repo. when a new repo is created, any pre-existing files are committed as an "initial" snapshot before any tool-triggered commits, so the diff history clearly shows what was present before vs what is new. after tool execution and heartbeats, changes are committed automatically.
hooks can return an actions array to trigger side effects:
{"actions": [
{"type": "send", "session_id": "...", "message": "...", "synthetic": true},
{"type": "create_session", "title": "..."}
]}see examples/hello/ for a complete working example. the hook script must:
- be executable
- accept the hook name as first argument (
sys.argv[1]) - read JSON from stdin
- write JSONL to stdout
- exit 0 on success
minimal python hook:
#!/usr/bin/env python3
import json, sys
HOOKS = {}
def hook(fn):
HOOKS[fn.__name__] = fn
return fn
@hook
def discover(ctx):
return {"tools": []}
@hook
def mutate_request(ctx):
return {"system": ["you are a helpful assistant."]}
if __name__ == "__main__":
h = HOOKS.get(sys.argv[1])
if not h:
print(json.dumps({"error": f"unknown hook: {sys.argv[1]}"}))
sys.exit(1)
ctx = json.loads(sys.stdin.read() or "{}")
result = h(ctx)
for key, value in result.items():
print(json.dumps({key: value}), flush=True)
{ "plugin": ["opencode-evolve"] }