Skip to content
Alex Clarke edited this page Oct 15, 2026 · 15 revisions

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.sh

That'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.


Quick start

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 file

The examples are inert until you wire them up. Installing them changes nothing by itself.

  • notify.sh posts a desktop notification (notify-send on Linux, osascript on macOS, falling back to a message on the controlling terminal, with control characters stripped so event text can't drive your terminal).
  • log-events.sh appends a timestamped snapshot of every COYOTE_* env var to ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log (override the path with COYOTE_HOOK_LOG). This is the fastest way to see exactly which events fire and what context each one carries. It filters out COYOTE_SECRET_* values.

Configuration

The hooks: map

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.

The three scopes

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 event

global_hooks: the agent whitelist

When 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_hooks means 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, but tool.* names the nonexistent event tool and admits nothing. Likewise tool.st* or *.no*fy admit 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.

Firing order and duplicates

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.

Working directory

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>/

How hooks run

  • Platform shell. The command line is passed to sh -c on Unix and cmd /C on Windows (pipes, redirects, and $VARS work 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.started or tool.started is 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.

Event catalog

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.

Turn events

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 events

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.

Tool events

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 events

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 fires llm.request.failed with kind aborted even though the turn itself recovers with the partial text (and then fires turn.interrupted).
  • api_error: The provider returned an error status; COYOTE_LLM_STATUS carries 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.

Agent events

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 --agent run fires agent.started once 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 --execute does, and a .agent step inside a macro opens and closes its own bracket (in both isolated and non-isolated macros). A --macro run without --agent involves no agent and fires nothing.
  • An interactive REPL with a loaded agent fires agent.started once 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 is turn.*'s job.
  • Spawned sub-agents fire their own bracket, with COYOTE_AGENT_ID set to the spawn id (e.g. agent_explore_a1b2c3). Top-level runs synthesize an id of the form top-<agent-name>-<nonce>.
  • In a graph, an agent node's failure fires agent.failed even when a declared fallback absorbs the failure and the node itself completes. Observers see the real failure; the node's graph.node.completed then reflects the fallback route.
  • There are no separate graph.started/graph.completed events: a top-level graph-agent run is bracketed by agent.* like any other agent.

When a run counts as interrupted

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 --agent runs, --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, fires agent.interrupted, not agent.failed.
  • Graph agent nodes make the same distinction: a node whose sub-agent was aborted fires agent.interrupted where an actual failure would have fired agent.failed.
  • Deliberate exits are not interruptions. Switching away from a REPL agent context (.agent back to none, or exit) and pressing Ctrl-C/Ctrl-D at an idle REPL prompt close the bracket as agent.completed because nothing was in flight to interrupt. The shell-assistant e(xit) path likewise always closes as a completion.

Escalation events

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), plus COYOTE_ESCALATION_FROM_AGENT_ID, COYOTE_ESCALATION_FROM_AGENT_NAME, and COYOTE_ESCALATION_QUESTION describing the asking agent, on both events, even though the answer is submitted by a different context. Matching COYOTE_ESCALATION_ID values pair a question with its reply.
  • escalation.answered adds COYOTE_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.answered run even inside agent contexts, with no global_hooks entry 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 no global_hooks wildcard 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 node events

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 events

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 events

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, or sse.
  • COYOTE_MCP_RECONNECT: true when 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: true when the failure is an authentication problem (e.g. an OAuth server with no stored token, so coyote --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.

Mesh events

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, private and/or public, comma-joined in config order; the kinds of the interfaces that were connected when mesh.started fired (an interface that comes up later refires nothing); mesh.stopped reports 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 and true or false for whether this node can talk to it.
  • COYOTE_MESH_FIRST_SEEN: true on a peer's first sighting, false on 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: direct or store-and-forward.

mesh.trust.granted / mesh.trust.revoked:

  • mesh.trust.granted fires only when .mesh trust adds 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 an Added grant that fires; clearing the old records' key-change marks rides along with it.
  • mesh.trust.revoked fires 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: untrust or forget on 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, unblock and every dry run. A hook learns of a refusal only from .mesh output.
  • COYOTE_MESH_TRUST_TIER: identity or destination.
  • 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, block or prune.

mesh.message.received / mesh.bulletin.received:

  • COYOTE_MESH_MESSAGE_KIND: message, ask or reply; always bulletin on mesh.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: direct or store-and-forward.
  • COYOTE_MESH_ROUTED (message only): envoy when the envoy took it, inbox when 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): direct or store-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: user when you set the brief text, digest when 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 grant and .mesh refuse take.
  • COYOTE_MESH_PATH_COUNT (requested only): how many paths the request names; the paths themselves are not carried.
  • COYOTE_MESH_DECISION (decided only): granted or denied.

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.


The payload file (tool events only)

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 (0600 on 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_FILE set. 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.log

Recipes

Recipe: escalation notifications

When 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}" ;;
esac

Because COYOTE_TOOL_NAME keeps prefixes intact, the same pattern works for MCP tools (mcp_*), agent spawns (agent__*), or any other family you care about.

Recipe: debugging your hooks

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>&1

Or 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).


Built-in example hooks

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 hooks prompts per conflict. When a file differs from the bundled version, you choose keep, replace, keep-all, replace-all, or abort per file; a keep-all/replace-all answer carries through the rest of the refresh across all three locations. In a non-interactive shell, conflicts resolve to keep.
  • 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).


Hooks in bundles

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 your hooks/ directory (tracked as the hooks category; installable alone with --filter hooks).
  • agents/<name>/hooks/ and roles/hooks/ ride along with the agents and roles categories, landing next to the configs whose relative ./hooks/... commands reference them.
  • Bundled agent configs may declare hooks:/global_hooks:, and bundled role frontmatter may declare hooks:. Those run whenever you use that agent or role.
  • Bundles can not modify your global hooks: wiring. There is no bundle merging into config.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.
  • hooks is a reserved bundle name (like the other asset categories).

Security and trust

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 bundled notify.sh shows 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.sh excludes COYOTE_SECRET_* by default.

What hooks deliberately do not do

These are design decisions, not gaps:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Windows notes

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).

Start Here

Using Coyote

Model Providers

Tools & Function Calling

Agents

Knowledge & Automation

Configuration

Clone this wiki locally