The single page was 76,549 bytes (9,020 words, 1,403 lines) and mixed a
quick-start tutorial, execution-semantics explanation, reference tables,
and two long how-to guides. It is now a 14,806 byte index that keeps the
quick start, troubleshooting, and deprecation sections, plus five children
grouped by reader job:
- plugins/hooks/reference.md — registration rules, execution contracts,
per-handler timeout budgets, and the complete typed hook catalog
- plugins/hooks/tool-policy.md — before_tool_call parameter rewrites,
blocks and approvals, sender-aware policy, resolve_exec_env, and
transcript persistence hooks
- plugins/hooks/prompt-and-session.md — model resolution and debug hooks,
prompt construction, authorized post-policy enrichment, finalization,
and durable session extensions
- plugins/hooks/messages.md — inbound claim, reply takeover, and outbound
delivery policy
- plugins/hooks/lifecycle.md — before_install, Gateway start/stop, cron
reconciliation, and safe external cron projection
Registration and the hook catalog stay on one child on purpose. Four
same-page directional references ("the execution contracts above", "the
shutdown session_end drain below", "the hook's contract below", "the
hook's failure policy below") keep resolving on that page, and at 22,792
bytes it is within the range of children already merged for the manifest,
sdk-runtime, and sdk-provider-plugins splits.
Anchor strategy: all 22 pre-split heading ids, computed with
parseDocsDocument from scripts/lib/docs-markdown.mjs rather than a slug
approximation, still resolve on /plugins/hooks. Six are still published by
the index itself; the other 16 are authored <a id="..."></a> stubs in a
"Where each section moved" list, matching the merged sdk-provider-plugins
index. parseDocsDocument reports zero authored/canonical id collisions on
the index and on every child, and all 16 stub targets resolve on their
child page. This keeps the source-code deep link
/plugins/hooks#upcoming-deprecations in src/plugins/compat/registry-records.ts
working; that section stays on the index.
Losslessness: every child body concatenates back byte-identically to the
original section bodies, and the index retains the quick start,
troubleshooting, upcoming-deprecations, and related sections verbatim.
18/18 code fences preserved with identical info strings and identical
bodies, compared fence by fence rather than by count. 96/96 table rows.
0 words lost (8,948 before, 9,245 after; the +297 is index and child
scaffolding). 23 markdown links before, 50 after, the +27 being the new
navigation.
One prose repair, the declared exception for an orphaned cross-reference:
"every hook in this page's catalog" became a link to the hook catalog,
since the catalog no longer lives on that page. One stale self-link,
/plugins/hooks#authorized-prompt-enrichment, was repointed at the child
that now owns the section, and four inbound deep links in other docs pages
were repointed at their new child pages.
Also registers the five children in docs.json navigation and adds their
titles to the zh-CN glossary.
Closes audit findings: r3-0523
6.8 KiB
| summary | read_when | title | sidebarTitle | |||
|---|---|---|---|---|---|---|
| Claim inbound messages and rewrite or cancel outbound deliveries |
|
Message and delivery hooks | Messages |
Inbound interception, reply takeover, and outbound delivery policy. Part of the Plugin hooks guide.
Message hooks
For inbound interception, before_dispatch receives the incoming message
before ordinary model dispatch. Return { handled: true, text: "..." } to
send a final reply, or { handled: true } to handle it without text. This is a
claim, not an API for rewriting outbound or inbound content.
reply_dispatch is the advanced takeover seam: it receives the finalized
message context and a host dispatcher, and a handled result reports
queuedFinal and delivery counts. Use before_agent_reply for a simple
synthetic reply, and the sending hooks below to transform outgoing payloads.
Runtime takeovers should forward ctx.onAgentRunStart,
ctx.userTurnTranscriptRecorder, and optional
ctx.prepareAssistantTranscriptMessage to their runtime helper. The ACP dispatch
helper forwards all three automatically. Share the recorder so the runtime and
Gateway do not append the same user turn independently; mark runtime
persistence only after a successful transcript write.
The host-provided preparer records display ownership before the canonical assistant append, using original runtime text captured before transcript-only hooks. It preserves raw content and IDs and grants no file access or write authority. Keep it in process and bound to its owning turn; after that turn aborts, is replaced, or completes, it returns the message unchanged.
The optional third onAgentRunStart argument can offer
completionSource: "reply-dispatch" with a getResult() callback. The host must
return "reply-dispatch" synchronously to accept completion ownership; observers
and other callback results leave lifecycle completion unchanged. Wrappers must
forward every callback argument and its return value. After dispatch settles,
getResult() supplies the canonical terminalOutcome and, when an
assistant write succeeded, its assistantTranscript receipt (target, message
ID, idempotency key, and optional projection anchor). The host then emits one
chat completion from the delivered, post-hook payloads while retaining runtime
lifecycle events. A receipt prevents a duplicate append; it does not authorize
writes to a replaced session. Omit this declaration for runtimes whose event
stream already owns chat completion.
Use eligibleDispatchKinds: ["acp"] for an ACP-only dispatcher. The host
classifies the resolved target, including conversation bindings, and passes
ctx.dispatchKind as acp or agent. Stored ACP metadata and ACP session keys
both select acp; a missing ACP binding does not fall back to agent dispatch.
The host applies the same eligibility check before invocation and when deciding
whether a hook prevents durable chat admission. An ACP-only hook therefore
does not block ordinary agent sessions. Omitted, empty, malformed, or partly
unknown eligibility lists remain unrestricted. Missing or unknown dispatch
context also keeps the hook eligible.
Use message hooks for channel-level routing and delivery policy:
message_received: observe inbound content, sender,threadId,messageId,senderId, optional run/session correlation, orderedmedia, normalizedlocation, stableproviderUpdateidentity when supplied by the channel, and metadata.message_sending: rewritecontentor return{ cancel: true }.reply_payload_sending: rewrite normalizedReplyPayloadobjects (includingpresentation,delivery, media refs, and text) or return{ cancel: true }.message_sent: observe final success or failure.
For audio-only TTS replies, content may contain the hidden spoken
transcript even when the channel payload has no visible text/caption.
Rewriting that content updates the hook-visible transcript only; it is not
rendered as a media caption.
reply_payload_sending events may include usageState, a best-effort live
per-turn model/usage/context snapshot. Durable delivery, recovered replay, and
replies without exact run correlation omit it.
Message hook contexts expose stable correlation fields when available:
ctx.sessionKey, ctx.runId, ctx.messageId, ctx.senderId, ctx.trace,
ctx.traceId, ctx.spanId, ctx.parentSpanId, and ctx.callDepth. Inbound
and before_dispatch contexts also expose reply metadata when the channel
has visibility-filtered quoted message data: replyToId, replyToIdFull,
replyToBody, replyToSender, and replyToIsQuote. Prefer these
first-class fields before reading legacy metadata.
before_dispatch receives the canonical inbound messageId in both its event
and context.
Prefer typed threadId and replyToId fields before using channel-specific
metadata.
Inbound claim and message-received events expose media?: PluginHookMediaFact[] as the canonical attachment API. Each fact can carry
path, url, contentType, kind, transcribed, messageId, and
workspaceDir; array position is attachment identity. When a remote attachment
has not been staged locally yet, media is omitted,
mediaStagingPending: true, and originalMedia contains the provider-side
facts. Do not treat originalMedia.path as locally readable until a later
staged event supplies media.
The singular/plural mediaPath, mediaUrl, mediaType, mediaPaths,
mediaUrls, mediaTypes, and matching originalMedia* metadata properties are
deprecated compatibility aliases. New hooks should use the typed top-level
arrays.
Decision rules:
message_sendingwithcancel: trueis terminal.message_sendingwithcancel: falseis treated as no decision.- Each
message_sendinghandler receives the original event content. The last returnedcontentwins; a later handler can still cancel delivery. reply_payload_sendingruns after payload normalization and before channel delivery, including replies routed back to the originating channel. Handlers run sequentially and each handler sees the latest payload produced by higher-priority handlers.reply_payload_sendingpayloads do not expose runtime trust markers such astrustedLocalMedia; plugins can edit payload shape but cannot grant local media trust.message_sendingcan returncancelReasonand boundedmetadatawith a cancellation. New message lifecycle APIs expose this as a suppressed delivery outcome with reasoncancelled_by_message_sending_hook; legacy direct delivery keeps returning an empty result array for compatibility.message_sentis observation-only. Handler failures are logged and do not change the delivery result.