Lightweight hook dispatch for external tool integration.
Loads hook configuration from ~/.deepagents/hooks.json and fires matching
commands with JSON payloads on stdin. Subprocess work is offloaded to a
background thread so the caller's event loop is never stalled. Failures are
logged but never bubble up to the caller.
Config format (~/.deepagents/hooks.json):
{"hooks": [{"command": ["bash", "adapter.sh"], "events": ["session.start"]}]}
If events is omitted or empty the hook receives all events.
Onboarding emits user.name.set with {"name": "...", "assistant_id": "..."}
after the user submits a non-empty preferred name.
tool.use fires before a tool call once its streamed arguments parse into a
complete value and its tool-call id is known; a call whose arguments never
parse, or that carries no id, is skipped. tool.result fires after every tool
call reaches a terminal state — successful execution, failure, or HITL
rejection/cancellation. The three blocks below show the payload shapes, not a
single sequence of events:
{"event": "tool.use", "tool_name": "write_file", "tool_id": "toolu_abc123",
"tool_args": {"file_path": "src/foo.py", "content": "..."}}
{"event": "tool.result", "tool_name": "write_file", "tool_id": "toolu_abc123",
"tool_args": {"file_path": "src/foo.py", "content": "..."},
"tool_status": "success", "tool_output": "Updated file src/foo.py"}
{"event": "tool.error", "tool_names": ["write_file"]}
tool_args is the parsed tool-call arguments; a non-object value (rare) is
wrapped as {"value": ...}. tool_output is the tool's returned content,
capped to HOOK_TOOL_OUTPUT_LIMIT characters (tool_args is not truncated); a
capped value ends with …[output truncated] so a consumer can tell a truncated
result from a short one.
tool_status is "success" or "error"; "error" covers both a tool that
raised and a call the user rejected or cancelled. Whenever a tool.result has
tool_status: "error", tool.error (payload {"tool_names": [<name>]}) fires
alongside it, so existing tool.error hooks are unaffected.
tool_args is {} whenever a tool.result cannot be correlated back to a
tool.use — either because the call carried no id (then tool_id is null) or
because no tool.use fired for it (e.g. its args never parsed), in which case
tool_id may still be the real string id.
Ordering: the tool events (tool.use, tool.result, tool.error) are
dispatched fire-and-forget (see dispatch_hook_fire_and_forget) and every
matching hook command runs in its own subprocess. A tool.use is dispatched
before its tool.result, but the two run concurrently, so a hook subscribed to
both may observe them out of order, and events from parallel tool calls
interleave freely. Correlate by tool_id rather than relying on arrival order —
there is no cross-event delivery-ordering guarantee for the tool events. Most
non-tool events (session.start, task.complete, session.end, user.prompt,
context.offload, context.compact, permission.request) fire in program order.
They are dispatched with an awaited dispatch_hook, except session.end on the
interactive TUI, which is dispatched via _dispatch_hook_sync at shutdown. That
dispatch runs on a worker thread (asyncio.to_thread) inside the coordinated
teardown in app.py so a slow hook can't block rendering or delay agent
cancellation — it overlaps agent cleanup and server shutdown, and teardown awaits
it before stopping the event loop, so it is dispatched once (never duplicated) and
after every prior non-tool event; the program-order guarantee therefore holds.
Delivery is at-most-once: a force-quit second exit can stop the loop before the
dispatch completes and drop it.
input.required and
user.name.set are the exceptions with no program-order guarantee:
user.name.set is always dispatched fire-and-forget, and input.required is
fire-and-forget on the headless surface (awaited only in the interactive TUI).