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 see persona
add to your opencode.jsonc:
set OPENCODE_EVOLVE_WORKSPACE to your workspace directory (default: ~/workspace).
~/workspace/
├── config/
│ └── evolve.jsonc # evolve settings
├── state/
│ └── evolve.json # runtime state (auto-managed)
├── hooks/
│ ├── persona.py # hook scripts (autodiscovered)
│ └── logger.py
├── prompts/ # prompt templates
└── tests/
├── persona_test.py # per-hook test scripts
└── logger_test.py
config/evolve.jsonc — all fields optional, all overridable via EVOLVE_* env vars:
{
"model": null, // "provider/model" or { providerID, modelID } (auto-detected if omitted)
"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)
}env var overrides: EVOLVE_MODEL, EVOLVE_HEARTBEAT_AGENT. env vars always take precedence over config file values.
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.
all executable files in hooks/ are autodiscovered and called as subprocesses (alphabetical order, files starting with . or __ are ignored):
$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).
multiple hooks run serially in alphabetical order. results are merged across all hooks:
- arrays (
system,tools,notifications,actions,modified) are concatenated - scalars (
continue,prompt,user,message,result) are concatenated with newline - errors trigger
recoverper failed hook; other hooks continue regardless
called once at plugin init. return registration metadata and tool definitions.
input:
{"hook": "discover"}output:
{"name": "persona", "test": "persona_test.py", "tools": [{"name": "my_tool", "description": "...", "parameters": {"arg": "description"}}]}registration fields (optional):
- name — tool prefix and hook identifier (defaults to filename stem). tools are registered as
<name>_<tool_name>. - test — test script filename in
tests/for hook validation (e.g.persona_test.py). if omitted, no validation is performed on hook writes.
parameters support two formats:
- string (backwards compat):
{"arg": "description"}— registers as a string param - typed:
{"arg": {"type": "string", "description": "...", "optional": true}}— registers with the specified type
supported types: string, number, boolean, object, array, any
tools can declare a permission field in their discover definition to enable fine-grained permission control via opencode's agent config. the permission.arg field specifies which tool argument(s) to use as the permission pattern:
{"name": "trait_write", "description": "...", "parameters": {...}, "permission": {"arg": "trait"}}
{"name": "trait_move", "description": "...", "parameters": {...}, "permission": {"arg": ["old_trait", "new_trait"]}}tools without a permission field default to patterns: ["*"] and can only be controlled with simple allow/deny.
agent config examples:
permission:
# simple allow/deny all invocations
persona_trait_delete: "deny"
persona_note_list: "allow"
# fine-grained by pattern (requires permission.arg in tool definition)
persona_trait_write:
"SOUL.md": "deny"
"*": "allow"
persona_data_update:
".people.json": "deny"
"*": "allow"
# restrict which hooks can be edited
evolve_hook_write:
"persona.py": "allow"
"*": "deny"
evolve_hook_edit:
"persona.py": "allow"
"*": "deny"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_list,hook_read,hook_write,hook_editlet the agent modify existing hook files. writes are validated against the hook's registered test script before installation. new hooks cannot be created or deleted. - prompt editing —
prompt_list,prompt_read,prompt_write,prompt_editlet the agent modify existing prompt templates. new prompts cannot be created or deleted. - tool discovery — custom tools defined by each hook's
discoverresponse are automatically registered with opencode.
tools are defined by each hook's discover response. each tool gets a prefixed name derived from the hook's registered name (or filename stem if not specified). for example, a hook returning {"name": "persona"} with a tool named trait_read registers as persona_trait_read.
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 prompts/ (bare filenames)<prefix>_prompt_read— read an existing prompt (supports offset/limit)<prefix>_prompt_write— overwrite an existing prompt (cannot create new files)<prefix>_prompt_edit— find-and-replace in an existing prompt (supports replaceAll)<prefix>_hook_list— list hook files in hooks/ (bare filenames)<prefix>_hook_read— read an existing hook (supports offset/limit)<prefix>_hook_write— overwrite an existing hook (validated if configured hook, cannot create new files)<prefix>_hook_edit— find-and-replace in an existing hook (validated if configured hook, supports replaceAll)<prefix>_hook_validate— validate hook content against the test suite without installing
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 {"name": "mybot", "test": "mybot_test.py", "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"] }