Hooks - Dark-Alex-17/coyote GitHub Wiki
Hooks let you run your own external commands whenever something interesting happens inside Coyote: a turn finishes, a tool runs, an LLM request fails, an agent completes, a sub-agent asks for your input, and so on. Typical uses are desktop notifications, activity logging, metrics emission, or kicking off downstream automation.
Hooks are pure observers:
- They are fire-and-forget. Coyote spawns the command detached and never waits for it to finish before moving on.
- Their exit status, stdout, and stderr are never read. A hook cannot change Coyote's output, behavior, or exit code, meaning a failing or missing hook is invisible to the session.
- They receive their context via environment variables (and, for tool events only, a small payload file).
# config.yaml
hooks:
turn.completed:
- name: notify
command: ./hooks/notify.shThat's the whole feature in one snippet: when a turn completes, Coyote runs ./hooks/notify.sh with COYOTE_EVENT,
COYOTE_HOOK_NAME, and friends in its environment.
Install the two bundled example hook scripts, then wire one up:
coyote --install-builtins hooks # installs notify.sh and log-events.sh into <config-dir>/hooks/# config.yaml
hooks:
turn.completed:
- name: notify
command: ./hooks/notify.sh # desktop notification when a turn finishes
tool.failed:
- name: log-events
command: ./hooks/log-events.sh # append the event's env snapshot to a log fileThe examples are inert until you wire them up. Installing them changes nothing by itself.
-
notify.shposts a desktop notification (notify-sendon Linux,osascripton macOS, falling back to a message on the controlling terminal, with control characters stripped so event text can't drive your terminal). -
log-events.shappends a timestamped snapshot of everyCOYOTE_*env var to${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log(override the path withCOYOTE_HOOK_LOG). This is the fastest way to see exactly which events fire and what context each one carries. It filters outCOYOTE_SECRET_*values.
All scopes share the same shape: an ordered map of event name -> list of named hooks, where each hook is a name
plus a shell command:
hooks:
<event>:
- name: <hook-name>
command: <shell command line>
- name: <another>
command: <another command line>Multiple hooks on the same event all fire. Unknown event names are ignored.
| Scope | Where | Fields | Semantics |
|---|---|---|---|
| Global | config.yaml |
hooks: |
In a plain (root) session (REPL, --headless, or a one-shot command, with or without a role) all global hooks run unconditionally. |
| Role | role frontmatter | hooks: |
Purely additive while the role is active, including a role held by a session. Roles have no global_hooks field and never gate anything. |
| Agent | agent config.yaml or graph.yaml (top level) |
hooks: + global_hooks:
|
The agent's own hooks: always apply while the agent runs. Global hooks are whitelist-only; see below. |
Global scope (config.yaml):
hooks:
turn.completed:
- name: notify
command: ./hooks/notify.sh
llm.request.failed:
- name: alert
command: curl -s -X POST https://example.com/webhook -d "provider=$COYOTE_LLM_PROVIDER"Role scope (frontmatter in roles/<name>.md):
---
hooks:
tool.completed:
- name: tool-log
command: ./hooks/log-events.sh
---
You are a helpful assistant...Agent scope (agents/<name>/config.yaml, or the top level of graph.yaml for graph agents. Hooks can never
be declared per graph node):
name: my_agent
hooks:
agent.completed:
- name: done-ping
command: ./hooks/notify.sh
global_hooks:
- turn.completed.notify # opt in to a specific global hook: <event>.<name>
- tool.failed.* # or every global hook on one eventWhen an agent context is active (a top-level --agent run or any spawned sub-agent), global hooks do not run by
default. The agent's global_hooks list is a whitelist of global hooks, where each entry is either an exact name or
one of four wildcard forms:
| Entry form | Admits |
|---|---|
<event>.<name> |
Exactly one global hook on exactly one event (turn.completed.notify) |
<event>.* |
Every global hook on that event (tool.failed.*) |
*.<name> |
The hook with that name on every event (*.notify) |
* or *.*
|
Every global hook on every event |
- An absent or empty
global_hooksmeans the agent runs no global hooks (except escalation events, which are never gated). - The gate applies recursively down the spawn tree: every spawned child agent is gated by its own
global_hooks, never by its parent's. -
Partial and inner globs are not wildcards. Event names contain dots and hook names never do, so
<event>.*requires a real, full event name:tool.failed.*is valid, buttool.*names the nonexistent eventtooland admits nothing. Likewisetool.st*or*.no*fyadmit nothing on any event. Invalid glob entries are inert and logged at debug level as invalid globs. - Exact entries that don't match any configured global hook are silently inert (logged at debug level). Valid wildcard entries are exempt from that unknown-entry diagnostic since what they admit legitimately varies with your global config.
This keeps an agent (and everything it spawns) from triggering every-event automation you configured for interactive use, unless the agent explicitly opts in.
For each event, effective hooks run in scope order: global -> role -> agent, in definition order within each scope. Duplicate hook names across scopes are allowed, as they all fire.
Each hook runs with its defining scope's directory as the working directory, so the canonical relative form
./hooks/<script> always resolves next to the config that declared it:
| Scope | Working directory |
|---|---|
Global (config.yaml) |
the Coyote config directory |
| Role frontmatter | the roles/ directory (so ./hooks/x.sh -> roles/hooks/x.sh) |
| Agent config/graph | the agent's directory: agents/<name>/
|
-
Platform shell. The command line is passed to
sh -con Unix andcmd /Con Windows (pipes, redirects, and$VARSwork as in that shell). - Detached and silent. Each hook is spawned as its own detached process with stdin, stdout, and stderr all connected to the null device. Nothing a hook prints can reach the session.
- Never read. Coyote awaits the child only to reap it; the exit status is discarded. Hook failures, including a command that fails to spawn at all, are recorded in Coyote's debug log and nowhere else.
- Ctrl-C-proof. On Unix each hook runs in its own process group, so a terminal Ctrl-C aimed at Coyote never reaches hook children.
- Orphans by design. Hook processes deliberately outlive Coyote. On exit paths (session end, final turn) Coyote briefly waits for pending hooks to spawn, never to finish, so exit hooks still launch.
-
No concurrency cap. Every matching hook of every event spawns its own process. Wiring an expensive command to a
chatty event like
llm.request.startedortool.startedis your responsibility to rate-limit. - Env values are bounded. Every env value is truncated to 2048 bytes (UTF-8-safe); long error messages and escalation questions arrive trimmed.
Every event carries this base environment:
| Variable | Value |
|---|---|
COYOTE_EVENT |
The dotted event name, e.g. turn.completed
|
COYOTE_HOOK_NAME |
The name: of the hook being run |
COYOTE_CONFIG_DIR |
Coyote's config directory |
COYOTE_EVENT_TIMESTAMP |
When the event fired (RFC 3339, UTC) |
COYOTE_SESSION_ID |
The session name, when a session is active. Re-read at each event; it can change after a save or rename. |
COYOTE_AGENT_NAME |
The active agent's name, when one is present |
The 46 events, and the extra variables each one adds:
| Event | Fires when | Extra env vars |
|---|---|---|
turn.started |
A user message starts processing (REPL, --headless, or one-shot command) |
COYOTE_ROLE † |
turn.completed |
The turn finished successfully |
COYOTE_ROLE † |
turn.interrupted |
The turn was cancelled with Ctrl-C |
COYOTE_ROLE † |
turn.failed |
The turn errored (not a Ctrl-C) |
COYOTE_ERROR, COYOTE_ROLE † |
session.started |
A new session is created |
COYOTE_ROLE † |
session.resumed |
An existing session is loaded instead of a new one being created |
COYOTE_ROLE † |
session.ended |
The active session is exited |
COYOTE_ROLE † |
session.compressed |
A session compression succeeded (failures fire nothing) |
COYOTE_ROLE † |
tool.started |
A tool call begins, after its name and arguments are resolved |
COYOTE_TOOL_NAME, COYOTE_HOOK_PAYLOAD_FILE
|
tool.completed |
The tool returned without error |
COYOTE_TOOL_NAME, COYOTE_TOOL_DURATION_MS, COYOTE_HOOK_PAYLOAD_FILE
|
tool.failed |
The tool errored, or the call failed before it could run |
COYOTE_TOOL_NAME, COYOTE_TOOL_ERROR, COYOTE_HOOK_PAYLOAD_FILE * |
llm.request.started |
Any LLM request begins |
COYOTE_LLM_PROVIDER, COYOTE_LLM_MODEL
|
llm.request.completed |
The request succeeded | + COYOTE_LLM_DURATION_MS
|
llm.request.failed |
The request failed or was aborted | + COYOTE_LLM_ERROR_KIND, COYOTE_LLM_ERROR, COYOTE_LLM_STATUS (api errors only) |
agent.started |
An agent run begins | COYOTE_AGENT_ID |
agent.completed |
The agent run finished successfully | COYOTE_AGENT_ID |
agent.interrupted |
The agent run was interrupted or cancelled | COYOTE_AGENT_ID |
agent.failed |
The agent run failed |
COYOTE_AGENT_ID, COYOTE_AGENT_ERROR
|
escalation.raised |
A sub-agent escalates a question for user input |
COYOTE_ESCALATION_ID, COYOTE_ESCALATION_FROM_AGENT_ID, COYOTE_ESCALATION_FROM_AGENT_NAME, COYOTE_ESCALATION_QUESTION
|
escalation.answered |
A reply is submitted for a pending escalation | + COYOTE_ESCALATION_REPLY
|
graph.node.started |
A graph node begins executing |
COYOTE_NODE_ID, COYOTE_NODE_TYPE
|
graph.node.completed |
The node finished | + COYOTE_NODE_DURATION_MS, COYOTE_NODE_ROUTE (when known) |
graph.node.failed |
The node failed | + COYOTE_NODE_DURATION_MS, COYOTE_NODE_ERROR
|
job.started |
A background job is started |
COYOTE_JOB_ID, COYOTE_TOOL_NAME
|
job.completed |
The job finished successfully |
COYOTE_JOB_ID, COYOTE_TOOL_NAME
|
job.failed |
The job failed |
COYOTE_JOB_ID, COYOTE_TOOL_NAME, COYOTE_JOB_ERROR
|
rag.sync.started |
A RAG knowledge base starts a real document sync (build or rebuild) |
COYOTE_RAG_NAME, COYOTE_RAG_PATH
|
rag.sync.completed |
The sync finished and the knowledge base was saved | + COYOTE_RAG_DOC_COUNT
|
rag.sync.failed |
The sync errored or was cancelled mid-sync |
COYOTE_RAG_NAME, COYOTE_RAG_PATH, COYOTE_ERROR
|
mcp.server.connected |
An MCP server was actually spawned and connected |
COYOTE_MCP_SERVER, COYOTE_MCP_TRANSPORT, COYOTE_MCP_RECONNECT (reconnects only) |
mcp.server.failed |
An MCP server spawn or connect failed |
COYOTE_MCP_SERVER, COYOTE_MCP_TRANSPORT, COYOTE_ERROR, COYOTE_MCP_AUTH_REQUIRED (auth failures only) |
mesh.started |
This session's mesh node joined |
COYOTE_MESH_INSTANCE_ID, COYOTE_MESH_DESTINATION, COYOTE_MESH_IDENTITY, COYOTE_MESH_INTERFACES
|
mesh.stopped |
The node left | same as mesh.started
|
mesh.peer.discovered |
A peer's announce was filed (throttled; see below) |
COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_NAME (omitted when absent), COYOTE_MESH_HOPS, COYOTE_MESH_PEER_PROTOCOL_VERSION, COYOTE_MESH_PEER_COMPATIBLE, COYOTE_MESH_FIRST_SEEN
|
mesh.knock.received |
An untrusted peer whose identity is on the trust list knocked |
COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_PEER_NAME (omitted when absent), COYOTE_MESH_KNOCK_INTRO (omitted when absent), COYOTE_MESH_VIA
|
mesh.trust.granted |
.mesh trust added a new record; a re-trust that only updates one (label, note) is silent |
COYOTE_MESH_TRUST_TIER, COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION (destination tier only) |
mesh.trust.revoked |
A record was removed by untrust, block or prune |
COYOTE_MESH_TRUST_TIER, COYOTE_MESH_PEER_IDENTITY (omitted when absent), COYOTE_MESH_PEER_DESTINATION (omitted when absent), COYOTE_MESH_TRUST_REASON
|
mesh.message.received |
A peer message, ask or reply arrived |
COYOTE_MESH_MESSAGE_KIND, COYOTE_MESH_MESSAGE_ID, COYOTE_MESH_IN_REPLY_TO (omitted when absent), COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_MESSAGE_TITLE (omitted when absent), COYOTE_MESH_VIA, COYOTE_MESH_ROUTED
|
mesh.bulletin.received |
A peer bulletin arrived |
COYOTE_MESH_MESSAGE_KIND, COYOTE_MESH_MESSAGE_ID, COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_MESSAGE_TITLE (omitted when absent), COYOTE_MESH_VIA
|
mesh.message.sent |
This node sent a message, ask or reply |
COYOTE_MESH_MESSAGE_KIND, COYOTE_MESH_MESSAGE_ID, COYOTE_MESH_PEER_DESTINATION (omitted when absent), COYOTE_MESH_VIA
|
mesh.message.failed |
A send failed |
COYOTE_MESH_MESSAGE_KIND, COYOTE_MESH_MESSAGE_ID, COYOTE_MESH_PEER_DESTINATION (omitted when absent), COYOTE_MESH_ERROR_CLASS, COYOTE_MESH_ERROR
|
mesh.bulletin.sent |
A broadcast finished |
COYOTE_MESH_MESSAGE_ID, COYOTE_MESH_RECIPIENTS, COYOTE_MESH_DELIVERED, COYOTE_MESH_STORED, COYOTE_MESH_UNREACHABLE, COYOTE_MESH_REFUSED
|
mesh.brief.updated |
The brief served to peers changed |
COYOTE_MESH_BRIEF_SOURCE, COYOTE_MESH_BRIEF_CHARS
|
mesh.fetch.served |
A peer fetched a file this node serves |
COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_SIZE, COYOTE_MESH_HASH_PREFIX
|
mesh.access.requested |
A trusted peer asked for paths this node does not share |
COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_ACCESS_ID, COYOTE_MESH_PATH_COUNT
|
mesh.access.decided |
.mesh grant or .mesh refuse answered an access request |
COYOTE_MESH_PEER_IDENTITY, COYOTE_MESH_PEER_DESTINATION, COYOTE_MESH_ACCESS_ID, COYOTE_MESH_DECISION
|
* A tool call that fails before its arguments are resolved (unknown tool, malformed arguments) fires tool.failed
with COYOTE_TOOL_NAME and COYOTE_TOOL_ERROR but no payload file.
† Every turn.* and session.* event carries COYOTE_ROLE with the active role's name, and only those two
families do. The name is resolved at fire time (a mid-session .role switch shows up on the next event), covers
roles held by the session and temp roles alike, and the variable is omitted entirely when no role is active.
session.ended fires while the role is still attached, so it carries the role the session ended with.
One user message produces exactly one turn.started and exactly one of turn.completed, turn.interrupted, or
turn.failed at the outer boundary of the REPL, --headless, and one-shot command modes. Tool-call rounds,
auto-continue rounds, and compression retries within the turn do not re-fire turn events. Ctrl-C wins: an
interrupted turn fires turn.interrupted even if an error was also in flight. Sub-agents and graph LLM nodes never
fire turn.*. Their traffic shows up under llm.request.*, tool.*, and agent.* instead.
session.started and session.resumed are two halves of the same boundary: whenever a session becomes active,
exactly one of them fires: session.started when the session is newly created, session.resumed when an existing
session is loaded instead. Both fire in the same set of places, so a hook wired to both sees every session
activation.
COYOTE_TOOL_NAME is the exact tool name as invoked, with prefixes intact: built-ins (fs_read), user-interaction
tools (user__select), agent tools (agent__spawn), and MCP tools (mcp_invoke_github, ...). This makes prefix
filtering in a hook script reliable.
llm.request.* fires for all LLM traffic: the main session, spawned sub-agents, and graph llm/structured nodes.
It is deliberately noisier than turn.*; use it for request-level metrics and use turn.* for message-level
notifications.
On llm.request.failed, COYOTE_LLM_ERROR_KIND is one of:
-
aborted: The request was cancelled (e.g. Ctrl-C mid-stream). Note: in the REPL, a Ctrl-C during streaming firesllm.request.failedwith kindabortedeven though the turn itself recovers with the partial text (and then firesturn.interrupted). -
api_error: The provider returned an error status;COYOTE_LLM_STATUScarries the HTTP status code. -
other: Anything else (network failures, parse errors, ...).
COYOTE_LLM_ERROR carries the immediate error message in every failed case, the same shape as
COYOTE_TOOL_ERROR, without the underlying cause chain.
The agent.* cadence is one bracket per agent run, never per message: agent.started once when the run begins,
then exactly one terminal event, agent.completed, agent.interrupted, or agent.failed, when it ends.
- A headless
--agentrun firesagent.startedonce when the run dispatches and its terminal event when it ends. Inspection invocations that don't dispatch the agent (e.g.--info,--list-sessions) fire nothing. -
Macros are bracketed too.
--agent <name> --macro <macro>fires the agent bracket exactly like--executedoes, and a.agentstep inside a macro opens and closes its own bracket (in both isolated and non-isolated macros). A--macrorun without--agentinvolves no agent and fires nothing. - An interactive REPL with a loaded agent fires
agent.startedonce when the agent context is entered (startup or agent switch) and the terminal event when it is exited (REPL exit or switch away). Per-message granularity isturn.*'s job. - Spawned sub-agents fire their own bracket, with
COYOTE_AGENT_IDset to the spawn id (e.g.agent_explore_a1b2c3). Top-level runs synthesize an id of the formtop-<agent-name>-<nonce>. - In a graph, an agent node's failure fires
agent.failedeven when a declared fallback absorbs the failure and the node itself completes. Observers see the real failure; the node'sgraph.node.completedthen reflects the fallback route. - There are no separate
graph.started/graph.completedevents: a top-level graph-agent run is bracketed byagent.*like any other agent.
agent.interrupted closes the bracket when the run was cut short rather than finishing or erroring:
-
Ctrl-C during an agent's turn (top-level
--agentruns,--execute, macros, and the REPL agent context alike) closes the bracket as interrupted. -
Cancellation is interruption. A spawned sub-agent cancelled via
agent__cancel, or swept up when a REPL Ctrl-C recursively cancels everything a turn spawned, firesagent.interrupted, notagent.failed. -
Graph agent nodes make the same distinction: a node whose sub-agent was aborted fires
agent.interruptedwhere an actual failure would have firedagent.failed. -
Deliberate exits are not interruptions. Switching away from a REPL agent context (
.agentback to none, orexit) and pressing Ctrl-C/Ctrl-D at an idle REPL prompt close the bracket asagent.completedbecause nothing was in flight to interrupt. The shell-assistante(xit) path likewise always closes as a completion.
When a spawned sub-agent needs input it cannot get for itself (a user__* question from deep in a spawn tree), it
escalates: the question travels up to the root agent (or you) and the sub-agent blocks until someone answers.
escalation.raised fires when the question is queued; escalation.answered fires when a reply is submitted.
- Both events carry
COYOTE_ESCALATION_ID(stable per escalation), plusCOYOTE_ESCALATION_FROM_AGENT_ID,COYOTE_ESCALATION_FROM_AGENT_NAME, andCOYOTE_ESCALATION_QUESTIONdescribing the asking agent, on both events, even though the answer is submitted by a different context. MatchingCOYOTE_ESCALATION_IDvalues pair a question with its reply. -
escalation.answeredaddsCOYOTE_ESCALATION_REPLY. "Answered" means a reply was submitted: the event fires even if the asker had already timed out or been cancelled by the time the reply arrived. -
Escalation events are never gated. Global hooks on
escalation.raised/escalation.answeredrun even inside agent contexts, with noglobal_hooksentry required. These hooks exist precisely so you hear about a child asking for help, so requiring every agent to whitelist them would silence exactly the events they were set up for. Role- and agent-scoped hooks on these events resolve normally, and noglobal_hookswildcard can narrow the exemption.
What does not fire escalation events: an escalation that times out or is dropped because the parent cancelled it
(nobody answered), the root agent's own direct user__* prompts (nothing escalates; hook tool.started for those),
and graph approval/user-input nodes.
Graph nodes in a parallel super-step fire graph.node.* concurrently, expect interleaving. COYOTE_NODE_TYPE is the
node's type (llm, agent, tool, end, ...), and COYOTE_NODE_ROUTE on graph.node.completed names the route
taken out of the node, when one was decided.
rag.sync.* brackets every real document sync (the expensive part of building or rebuilding a RAG knowledge
base) no matter which door it goes through: an agent's RAG built on --agent startup, graph rag node builds,
the REPL's .rag (both the temp RAG and a named RAG that isn't built yet), .edit rag-docs, .rebuild rag, and a
CLI --rag run. One sync fires exactly one rag.sync.started and then exactly one of rag.sync.completed (with
COYOTE_RAG_DOC_COUNT, the number of files in the knowledge base) or rag.sync.failed (with COYOTE_ERROR).
Cancelling a sync with Ctrl-C counts as a failure: the knowledge base was not (re)built.
What does not fire rag.sync.*: loading an already-built knowledge base from disk, a warm in-process cache hit
(agents and graph nodes share built RAGs within a run), and attaching to an external RAG database with .rag attach.
Only a sync that actually ingests documents reports anything.
mcp.server.connected fires when an MCP server is actually spawned (stdio) or connected (http/sse), and never when
a live, already-connected server is handed out again. Extra variables:
-
COYOTE_MCP_SERVER: the server's configured name. -
COYOTE_MCP_TRANSPORT:stdio,http, orsse. -
COYOTE_MCP_RECONNECT:truewhen the same server was connected earlier in this process and died or was dropped, so this spawn is a reconnect. Omitted entirely on a first connect.
mcp.server.failed fires when a spawn or connect attempt fails, with COYOTE_ERROR and:
-
COYOTE_MCP_AUTH_REQUIRED:truewhen the failure is an authentication problem (e.g. an OAuth server with no stored token, socoyote --auth-mcp <server>would fix it). Omitted on every other kind of failure.
Because servers are shared and reused within a process, these events track real connection churn, not usage: an agent reusing a sibling's live server fires nothing.
Known limitation: servers started at boot from mcp.json's startup list connect before any hook scope exists,
so those boot-time connects fire no mcp.server.* events. Connects made on demand after startup (agent MCP tools,
.mcp REPL usage, sub-agent spawns) all report normally.
The fifteen mesh.* events report what this session's Coyote Mesh node does: joining and leaving, peers
heard, knocks (an untrusted node asking to be trusted), trust changes, messages in and out, broadcasts, brief updates,
files served and access requests asked and decided. Every variable is a hash, an enum, a count or a short
peer-supplied string. Message content, the wire fields, the brief text, file paths, file contents and the private
key are never present: an allow-list of variables per event is enforced when the hook fires, and nothing outside it
gets through. No mesh event writes a payload file.
mesh.started / mesh.stopped:
-
COYOTE_MESH_INSTANCE_ID: this session's mesh instance id. -
COYOTE_MESH_DESTINATION: the destination hash peers address. -
COYOTE_MESH_IDENTITY: this config dir's identity hash. -
COYOTE_MESH_INTERFACES:lan,privateand/orpublic, comma-joined in config order; the kinds of the interfaces that were connected whenmesh.startedfired (an interface that comes up later refires nothing);mesh.stoppedreports the kinds connected when the node left.
mesh.peer.discovered:
-
COYOTE_MESH_PEER_DESTINATION,COYOTE_MESH_PEER_IDENTITY: the peer's hashes. -
COYOTE_MESH_PEER_NAME: the peer's display name. Omitted when it announced none. -
COYOTE_MESH_HOPS: hop count to the peer. -
COYOTE_MESH_PEER_PROTOCOL_VERSION,COYOTE_MESH_PEER_COMPATIBLE: the peer's mesh protocol version andtrueorfalsefor whether this node can talk to it. -
COYOTE_MESH_FIRST_SEEN:trueon a peer's first sighting,falseon a refresh.
mesh.knock.received:
-
COYOTE_MESH_PEER_IDENTITY,COYOTE_MESH_PEER_DESTINATION: who knocked, from which instance. -
COYOTE_MESH_PEER_NAME: omitted when absent. -
COYOTE_MESH_KNOCK_INTRO: the knocker's text, at most 200 characters. Omitted when absent. -
COYOTE_MESH_VIA:directorstore-and-forward.
mesh.trust.granted / mesh.trust.revoked:
-
mesh.trust.grantedfires only when.mesh trustadds a record. Trusting a destination or identity that is already trusted updates the record (a new label, a lifted refusal) and fires nothing. Trusting the new destination of a rotated instance is anAddedgrant that fires; clearing the old records' key-change marks rides along with it. -
mesh.trust.revokedfires once per record that was actually granting trust: an untrust or block of an identity fires for the identity tier only if that row granted identity-tier trust, plus once per destination record it removed. Removing an identity row that granted nothing fires nothing. Nor does a refusal:untrustorforgeton an instance of an identity trusted for all keeps the record and writes a refusal, which fires nothing, as do the record it binds for an instance that had none, a lift of that refusal by.mesh trust,unblockand every dry run. A hook learns of a refusal only from.meshoutput. -
COYOTE_MESH_TRUST_TIER:identityordestination. -
COYOTE_MESH_PEER_IDENTITY: always set on a grant; omitted on a revocation when the record carried none. -
COYOTE_MESH_PEER_DESTINATION: set for destination-tier records. Omitted for identity-tier ones. -
COYOTE_MESH_TRUST_REASON(revoked only):untrust,blockorprune.
mesh.message.received / mesh.bulletin.received:
-
COYOTE_MESH_MESSAGE_KIND:message,askorreply; alwaysbulletinonmesh.bulletin.received. -
COYOTE_MESH_MESSAGE_ID: the peer's message id. -
COYOTE_MESH_IN_REPLY_TO(message only): the id this message answers. Omitted when absent. -
COYOTE_MESH_PEER_IDENTITY,COYOTE_MESH_PEER_DESTINATION: the sender. -
COYOTE_MESH_MESSAGE_TITLE: the subject line, at most 120 characters. Omitted when absent. -
COYOTE_MESH_VIA:directorstore-and-forward. -
COYOTE_MESH_ROUTED(message only):envoywhen the envoy took it,inboxwhen it went to the inbox.
mesh.message.sent / mesh.message.failed:
-
COYOTE_MESH_MESSAGE_KIND,COYOTE_MESH_MESSAGE_ID: what was sent. -
COYOTE_MESH_PEER_DESTINATION: the recipient. Omitted when the failure happened before one was resolved. -
COYOTE_MESH_VIA(sent only):directorstore-and-forward. -
COYOTE_MESH_ERROR_CLASS(failed only): one of the send error kinds listed on Mesh. -
COYOTE_MESH_ERROR(failed only): the error text, at most 512 characters.
mesh.bulletin.sent:
-
COYOTE_MESH_MESSAGE_ID: the bulletin's id. -
COYOTE_MESH_RECIPIENTS,COYOTE_MESH_DELIVERED,COYOTE_MESH_STORED,COYOTE_MESH_UNREACHABLE,COYOTE_MESH_REFUSED: integer tallies.
mesh.brief.updated:
-
COYOTE_MESH_BRIEF_SOURCE:userwhen you set the brief text,digestwhen the model refreshed the digest. -
COYOTE_MESH_BRIEF_CHARS: the brief's length in characters.
mesh.fetch.served:
-
COYOTE_MESH_PEER_IDENTITY,COYOTE_MESH_PEER_DESTINATION: who fetched. -
COYOTE_MESH_SIZE: the file's size in bytes. -
COYOTE_MESH_HASH_PREFIX: the first 8 hex characters of the file's sha256. The path is not carried.
mesh.access.requested / mesh.access.decided:
-
COYOTE_MESH_PEER_IDENTITY,COYOTE_MESH_PEER_DESTINATION: who asked. -
COYOTE_MESH_ACCESS_ID: the request's id, the one.mesh grantand.mesh refusetake. -
COYOTE_MESH_PATH_COUNT(requested only): how many paths the request names; the paths themselves are not carried. -
COYOTE_MESH_DECISION(decided only):grantedordenied.
Mesh events are node-level, so they resolve global hooks only. An agent's global_hooks whitelist is not
consulted, even for mesh.message.sent, mesh.message.failed and mesh.bulletin.sent triggered by an agent's
mesh__* tool call, and COYOTE_SESSION_ID, COYOTE_AGENT_NAME and COYOTE_ROLE are not set. The base variables
COYOTE_EVENT, COYOTE_CONFIG_DIR, COYOTE_EVENT_TIMESTAMP and COYOTE_HOOK_NAME are.
mesh.peer.discovered fires for every peer, trusted or not, but is throttled: a new peer always fires; a re-announce
fires only on a visible change (name, protocol version or hop count) or every 3600 s; and at most 16 fires per second
get through overall. A first sighting dropped by the rate cap shows up on the peer's next refresh with
COYOTE_MESH_FIRST_SEEN=false.
Ordering: mesh.peer.discovered and mesh.knock.received can precede mesh.started, and mesh.brief.updated can
fire while the mesh is off.
Peer-supplied values. COYOTE_MESH_PEER_NAME, COYOTE_MESH_KNOCK_INTRO and COYOTE_MESH_MESSAGE_TITLE are text
another node chose. Quote them in scripts and never eval them.
tool.* events expose the tool call's arguments through COYOTE_HOOK_PAYLOAD_FILE: the path to a temporary JSON file
containing exactly the tool's arguments object (flat JSON, the same object the tool itself received). Contract:
- Tool events only. No other event writes a payload file; everything else is env-vars-only.
-
One file per hook invocation, created fresh with owner-only permissions (
0600on Unix) and a random nonce in the filename. On Windows the file lands under the per-user%TEMP%directory, which is the isolation boundary. - Read-only. Treat it as such. It is deleted (best-effort) as soon as your hook process exits. Copy the content out if you need it afterwards.
- If the file cannot be written, the hook still runs, just without
COYOTE_HOOK_PAYLOAD_FILEset. Scripts should guard on the variable's presence.
# inside a tool.* hook
[ -n "${COYOTE_HOOK_PAYLOAD_FILE:-}" ] && jq . "$COYOTE_HOOK_PAYLOAD_FILE" >> /tmp/tool-args.logWhen you run Coyote unattended (or just in another tmux window), the moment it needs you is when a sub-agent
escalates a question. Hook escalation.raised globally; escalation events bypass the agent whitelist, so this works
with no global_hooks opt-in on any agent, no matter how deep the spawn tree:
# config.yaml
hooks:
escalation.raised:
- name: escalation-ping
command: ./hooks/escalation-ping.sh#!/usr/bin/env bash
# hooks/escalation-ping.sh: notify when a sub-agent is waiting on input
notify-send "coyote needs input" \
"${COYOTE_ESCALATION_FROM_AGENT_NAME:-agent}: ${COYOTE_ESCALATION_QUESTION:-—}"For the root agent's own user__* prompts (which never escalate and therefore don't fire escalation events), or
for per-tool granularity, the tool-event variant still works. Hook tool.started and filter on the tool-name
prefix:
# config.yaml
hooks:
tool.started:
- name: user-tool-ping
command: ./hooks/user-tool-ping.sh#!/usr/bin/env bash
# hooks/user-tool-ping.sh: notify only when coyote is asking the user something
case "${COYOTE_TOOL_NAME:-}" in
user__*) notify-send "coyote needs input" "${COYOTE_TOOL_NAME}" ;;
esacBecause COYOTE_TOOL_NAME keeps prefixes intact, the same pattern works for MCP tools (mcp_*), agent spawns
(agent__*), or any other family you care about.
Hooks are silent by design. Coyote discards their output. To see what a hook is doing, pipe to a file yourself:
hooks:
turn.completed:
- name: my-hook
command: ./hooks/my-hook.sh >> /tmp/my-hooks.log 2>&1Or wire up the bundled log-events.sh on the event you're investigating: it dumps every COYOTE_* variable
(excluding COYOTE_SECRET_*) per event to ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log (override with
COYOTE_HOOK_LOG), creating the log with owner-only permissions and refusing to append through a symlink.
Spawn failures (bad command, missing script, permissions) appear in Coyote's debug log
(coyote --tail-logs, with the log level set to DEBUG. See Environment Variables).
coyote --install-builtins hooks (REPL: .install hooks) refreshes the bundled hook scripts across all three hook
locations: <config-dir>/hooks/ (the two example scripts), roles/hooks/, and agents/<name>/hooks/ (scripts
that ship with built-in roles and agents). Override the top-level directory with COYOTE_HOOKS_DIR; note that
relative global-hook commands still resolve against the config directory, and Coyote warns about the mismatch at
install time.
Install semantics:
- Startup is silent. Coyote's automatic install-on-startup only creates files that don't exist yet. It never prompts and never touches a file you've edited.
-
--install-builtins hooksprompts per conflict. When a file differs from the bundled version, you choosekeep,replace,keep-all,replace-all, orabortper file; akeep-all/replace-allanswer carries through the rest of the refresh across all three locations. In a non-interactive shell, conflicts resolve tokeep. -
Removed scripts are cleaned up. A hidden manifest tracks which files Coyote itself installed, so a script a
release stops shipping is removed on the next refresh, including the hooks of a built-in agent that no longer
exists. Files you created yourself in any
hooks/directory are never touched. -
Executable bits are by extension (Unix only). Files with a recognized script extension are installed
0755; anything else is written plain, so support files next to your scripts stay non-executable. On Windows there are no exec bits and installs succeed without them.
Treat the bundled scripts as Coyote-owned; copy before customizing (or answer keep when a refresh prompts).
Bundles can ship hook scripts, and can wire hooks into the agents and roles they ship:
- A top-level
hooks/directory in a bundle installs into yourhooks/directory (tracked as thehookscategory; installable alone with--filter hooks). -
agents/<name>/hooks/androles/hooks/ride along with theagentsandrolescategories, landing next to the configs whose relative./hooks/...commands reference them. - Bundled agent configs may declare
hooks:/global_hooks:, and bundled role frontmatter may declarehooks:. Those run whenever you use that agent or role. - Bundles can not modify your global
hooks:wiring. There is no bundle merging intoconfig.yaml. Global hook wiring is exclusively user-authored; a bundle README may suggest a snippet to paste, and pasting it is exactly the trust decision it looks like. - For visibility, the installer prints one line per hook script it plans to write
(
bundle defines hook script: hooks/notify.sh). There is no approval prompt. Review bundles before installing them. -
hooksis a reserved bundle name (like the other asset categories).
A hook is an arbitrary command executed with Coyote's inherited environment, on every firing of its event. Concretely:
- Installing a bundle (or using an agent or role) that defines hooks means trusting its author with command execution on your machine.
- Event context can include model-influenced values (tool names, error text, escalation questions) and, on
mesh.*events, text chosen by a remote node (peer name, knock intro, message title). The bundlednotify.shshows the defensive pattern: sanitize anything you interpolate into another interpreter. - Hook env snapshots can contain whatever Coyote's process environment contains. If you log them, filter. The
bundled
log-events.shexcludesCOYOTE_SECRET_*by default.
These are design decisions, not gaps:
-
No general payload files. The temp JSON file carries tool arguments only, on
tool.*events only. All other event context is delivered as environment variables. There is no per-event JSON bundle of general context. -
No bundle merging into
config.yaml. Bundles ship hook scripts plus agent/role-scoped wiring only. Nothing a bundle installs can silently add commands that execute in every session; global wiring is always your edit. -
No LLM request/response body exposure.
llm.request.*carries metadata only (provider, model, duration, error). Request/response bodies embed system prompts and memory, which is an exfiltration surface, and are far too large for the hot path. - No runtime influence, ever. Hook results are never read; nothing blocks on a hook; there are no tool shims.
Also currently out of scope: session-scoped hooks, per-graph-node hooks, and hook concurrency caps.
Hooks run via cmd /C, with the command line passed verbatim to cmd. Payload files are created under the per-user
%TEMP% directory; that per-user isolation is the protection boundary (no extra ACLs are applied). Executable-bit
handling is Unix-only, installs succeed on Windows without it, so make sure the command line itself is runnable
(e.g. bash hooks\notify.sh if you rely on shell scripts).