Skip to content

Hooks

Run shell commands on tool and session events, without writing a plugin.

Lunos uses the hooks config to run shell commands when something happens — a tool is about to run, a file was just edited, a session went idle. Anything you can do in a hook you could also do in a plugin, but hooks need no code: they’re config.


Example

Format TypeScript after every edit.

opencode.json
{
"$schema": "https://opencode.ai/config.json",
"hooks": {
"tool.execute.after": [
{
"matcher": { "tool": "edit", "file": "**/*.ts" },
"command": ["prettier", "--write", "$LUNOS_FILE"]
}
]
}
}

Entries

Each event maps to a list of entries, run in order.

KeyRequiredDescription
commandyesCommand and arguments, as an array.
matchernoRestricts when the hook fires. Omit to fire on every occurrence of the event.
environmentnoExtra environment variables, on top of the inherited environment.
timeoutnoMilliseconds before the command is killed. Defaults to 30000.
disablednoKeeps the hook configured but inactive.

command is an array, not a string. It’s executed directly rather than through a shell, so there’s no quoting to get right and no injection risk from a path containing spaces or ;.

Matchers

KeyDescription
toolGlob matched against the tool name, e.g. edit or *.
fileGlob matched against the file path the tool acted on, e.g. **/*.ts.

Both must match for the hook to fire. A matcher that needs information the event doesn’t carry never fires — a file matcher on a session event stays silent rather than matching everything.

Environment

Every hook command receives:

VariableDescription
LUNOS_HOOK_EVENTThe event that fired the hook.
LUNOS_TOOLThe tool name, when the event has one.
LUNOS_FILEThe file the tool acted on, when there is one.
LUNOS_SESSION_IDThe session, when the event has one.
LUNOS_AGENTThe agent running the tool, on tool events.
LUNOS_SKILLSkills loaded this turn, comma-separated. Unset when none.

Events

EventWhenBlocking
tool.execute.beforeBefore a tool runsyes
tool.execute.afterAfter a tool returnsno
command.execute.beforeBefore a custom command runsyes
session.createdA session was createdno
session.idleA session finished workingno
session.compactedA session’s context was compactedno
session.deletedA session was deletedno
session.errorA session erroredno

Only these event names are recognised. A misspelled event is silently ignored — config decoding drops unknown keys rather than rejecting them, so the hook simply never fires and you get no warning. If a hook seems to do nothing, check the event name against this table first.

Blocking

A before hook that exits non-zero vetoes the action — the tool or command does not run, and the hook’s stderr is surfaced as the reason. This is what makes a guard possible.

opencode.json
{
"hooks": {
"tool.execute.before": [
{
"matcher": { "tool": "bash" },
"command": ["./scripts/guard-bash.sh"]
}
]
}
}

Everything else is observational: a non-zero exit is logged and execution continues. A formatter that fails shouldn’t undo a tool call that already succeeded.

A hook that hangs is killed at its timeout. On a blocking event that counts as a veto, so keep timeout above the slowest legitimate run.


Scope

Lunos hooks cover the events Lunos actually has. If you’re coming from another agent, some events have no equivalent here:

ElsewhereLunos
Pre/post tool usetool.execute.before / tool.execute.after
Session startsession.created
Session end / stopsession.idle is the closest — it fires when a session finishes working, not when it’s torn down
Compactionsession.compacted
User prompt submittedNo equivalent event is dispatched today
Subagent lifecycleNo equivalent — Lunos doesn’t model subagents as a separate session lifecycle
NotificationNo equivalent

These are gaps in what Lunos dispatches, not in the hooks feature. Adding an event to this table means dispatching it in the agent loop first.


Plugins

Hooks and plugins share one execution path — config hooks are implemented as a built-in plugin over the same dispatch, so ordering between them is predictable and there’s no second system to reason about.

Reach for a plugin instead of a hook when you need to inspect or rewrite a tool’s arguments, talk to the Lunos server, or keep state between events. Hooks are for shelling out.