🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: wire chat lifecycle hooks into chatd - #27429

Merged
ibetitsmike merged 40 commits into
mainfrom
mike/chat-hooks/chatd
Jul 29, 2026
Merged

feat: wire chat lifecycle hooks into chatd#27429
ibetitsmike merged 40 commits into
mainfrom
mike/chat-hooks/chatd

Conversation

@ibetitsmike

@ibetitsmike ibetitsmike commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Wires chat lifecycle hooks into chatd, gated by the agent-lifecycle-hooks experiment. Part of the lifecycle hooks stack (#27401, #27428, #27430). See docs/admin/setup/chat-lifecycle-hooks.md for the consumer-facing contract.

Summary

When a hook URL is configured, chatd dispatches session_start, user_prompt_submit, pre_tool_use, post_tool_use, pre_compact, post_compact, and stop events to the consumer and applies its responses.

Design

  • Stateless: Coder stores no hook dispatch or decision state. Delivery is at least once; consumers deduplicate on stable payload identifiers (chat ID, event type, tool-use ID) and answer duplicates with the same decision.
  • Admission-time prompt effects: user_prompt_submit dispatches exactly once per submission (create, send, queue, edit, subagent spawn) and folds its effects into the stored prompt as typed message parts: original-or-overridden user parts, then model-only hook-context, then a user-visible hook-notice. Hook context is stripped from every client-facing conversion; hook notices are excluded from model prompts. The server rejects hook parts in client-submitted content.
  • Tool gating: pre_tool_use allow can override tool input; deny becomes a synthetic denied tool result, with any returned model context persisted as a model-only transcript row so it never reaches clients. The denial text identifies an external policy (the deployment's lifecycle hook) as the source and marks the decision as persistent, so the model explains the denial instead of retrying it or misreporting it as an infrastructure failure.
  • Fail closed: a dispatch failure rejects the triggering request or moves the chat to the error state in the same transaction as the affected step, so a runnable state is never published with unapproved content.
  • Admission before persistence: pre_tool_use is dispatched for the calls the model produced, before the assistant message is stored. See "Staged tool admission" below.
  • Fresh dispatch per tool call: every non-provider-executed tool call is decided by its own pre_tool_use dispatch; Coder never reuses an earlier decision on the consumer's behalf. Retries re-dispatch the same logical event.

Structure

All hook dispatch flows through one seam: entry points build a chathooks.Chat (chat identity) and a chathooks.Message (event details) and call Trigger.Trigger, the only component that talks to the dispatcher. The integration lives in the coderd/x/chatd/chathooks subpackage, split by responsibility:

  • trigger.go: the trigger seam; builds the wire envelope per event, normalizes deny into a typed error, and holds the package's single enabled-check.
  • effects.go: pure conversion of hook results into transcript rows and prompt parts.
  • errors.go: failure classification (dispatch error messages, denial mapping, tool-result dispatch-failure scanning).
  • tooluse.go: the tool-call gate (pre_tool_use preflight, post_tool_use payloads, applying admitted input to the step).

Server-bound glue stays in coderd/x/chatd/hook_server.go: the chat-parking dispatch error handlers, the step-commit row insertion wrappers, and the dynamic post-tool-use state loader, which depends on chatd validation types.

This PR adopts the codersdk/x/agenthooks and coderd/x/agenthooks/dispatch import paths introduced at the tip of #27401; intermediate commits still reference the pre-move paths and are not individually buildable.

Staged tool admission

pre_tool_use originally ran at tool execution time, which is after the assistant message carrying the tool call was already committed. An input_override therefore had to rewrite stored message content in place. @hugodutka pointed out that chatd treats message content as immutable, and that the rewrite was a shortcut rather than a requirement.

It was also a correctness problem in its own right: the rewrite only updated the database, so the transcript could show one input while a different one had executed.

The hook now runs before the step is persisted:

provider stream ends (tool calls complete, in memory)
  -> pre_tool_use dispatch per call
  -> ONE transaction: assistant row with admitted inputs, synthetic denials, hook rows
  -> execute

The step is inserted once, carrying the input the tool runs with. UpdateChatMessageContentByID and Tx.UpdateMessageContent are deleted from #27428, so message content stays immutable.

Two consequences, both intentional:

  • Clients converge rather than wait. Tool-call parts still stream live, so a rewritten call briefly shows the model's proposed input before the committed message replaces it. The chat store already clears stream state when an assistant message arrives, so the stored input wins with no frontend change and no added latency before tool cards appear.
  • A call already in history was already admitted. Execution consumes the stored input instead of dispatching a second decision, which keeps one dispatch and one set of hook effects per call. A consumer policy change between admission and execution applies to later calls, not to calls already admitted.

The per-chat debug endpoint still records the provider's original tool input. Its purpose is to report provider behavior, and it requires an explicit per-chat debug flag; the invariant here covers the transcript.

Configuration

Adds chat-hook-url, chat-hook-secret, chat-hook-timeout, and chat-hook-enabled deployment options with startup validation. The flags are hidden from coder server --help while the feature is experimental; the setup guide documents them.

Tool input validation

Built-in tool arguments reach a consumer as raw JSON with key spelling preserved, but the tools decode those bytes with Go, which matches struct fields case-insensitively and keeps the last match. A policy reading path could therefore authorize one value while the tool executed another, and a lone case variant such as {"PATH":"/secret"} was invisible to a policy checking for path.

Coder now rejects a built-in tool call whose input repeats a key or spells a schema property with different capitalization, before the pre_tool_use dispatch, so a consumer is never asked to authorize bytes whose meaning depends on the reader. Rejected calls produce an error result the model can retry; unambiguous calls in the same batch still run. A consumer-authored input_override is rechecked after the dispatch and fails the turn closed, because the model cannot correct it. Dynamic and MCP inputs are excluded because the client and the workspace agent execute those calls rather than coderd.

Two paths needed more than a schema check. Execution resolves a deprecated tool name to its canonical tool, so validation resolves aliases first. The edit_files decoder also reads search and replace, which its schema does not advertise, so those aliases are now matched exactly and their case variants ignored.

A hook denial now returns a structured 403 carrying kind: "hook_denied", mirroring the dispatch-failure response that already carries its own kind. Without it a client cannot tell a policy decision apart from a generic failure, and the chat UI titled a denial "Request failed". Adding a kind needs no migration: ChatErrorKind is persisted only inside the JSONB chats.last_error column, whose decoder accepts unknown kinds.

The hook docs also correct the tool-input convergence window. A batch dispatches sequentially before the assistant row commits, so the original input stays visible for a span that scales with the number of tool calls in the step rather than a single hook timeout.

This PR was written by Mux, an AI coding agent, on Mike's behalf.

ibetitsmike commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 953ead293d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/x/chatd/hooks.go Outdated
Comment thread coderd/x/chatd/chatd.go Outdated
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatstate branch from 9fefafe to e0b7344 Compare July 22, 2026 19:59
@ibetitsmike
ibetitsmike requested a review from Emyrk as a code owner July 22, 2026 19:59
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatd branch from 953ead2 to bbc1484 Compare July 22, 2026 19:59
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatstate branch from e0b7344 to a05cf3c Compare July 22, 2026 20:12
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatd branch from bbc1484 to d0f7244 Compare July 22, 2026 20:12
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0f7244efd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/x/chatd/chatd.go Outdated
Comment thread coderd/x/chatd/hooks.go Outdated
Comment thread coderd/x/chatd/hooks.go Outdated
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatstate branch from a05cf3c to 841eefb Compare July 22, 2026 20:24
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatd branch from d0f7244 to 5250a86 Compare July 22, 2026 20:24
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5250a861a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/x/chatd/chatd.go Outdated
Comment thread coderd/x/chatd/hooks.go Outdated
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatd branch from 5250a86 to e62369a Compare July 22, 2026 20:37
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e62369a8af

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/x/chatd/hooks.go Outdated
@Emyrk
Emyrk removed their request for review July 22, 2026 20:47
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatstate branch from 841eefb to e08f7af Compare July 22, 2026 20:51
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatd branch from e62369a to d835b06 Compare July 22, 2026 20:52
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d835b06b87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread coderd/x/chatd/chatd.go Outdated
Comment thread coderd/x/chatd/chatd.go Outdated
@ibetitsmike
ibetitsmike force-pushed the mike/chat-hooks/chatd branch from d835b06 to a3855c0 Compare July 22, 2026 21:27
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

Rewrite chatd, coderd, and docs references from codersdk/agenthooks and
coderd/x/chathooks to coderd/x/hooks and coderd/x/hooks/dispatch,
including the dispatch.Error and dispatch.Result renames.
Fold the triplicated lifecycle hook denial and dispatch failure mapping
in the chat create, send, and edit handlers into writeChatHookErr, and
share one core between applyHookResultMessages and
appendHookResultMessages.
…odel

Replace the terse DENIED prefix in synthetic denial tool results with
text that identifies the lifecycle hook policy as the source, marks the
decision as persistent, and directs the model to explain the denial
instead of retrying. Models treated the old shape as an ordinary tool
failure and misreported denials as workspace or infrastructure errors.
…package

Move the lifecycle hook trigger seam, transcript effects, tool gate,
and error classification from chatd into coderd/x/chatd/chathooks,
exporting the moved symbols. Server-bound glue stays in chatd
(hook_server.go): the chat-parking dispatch error handlers, the
step-commit row insertion wrappers, and the dynamic post-tool-use state
loader, which depends on chatd validation types. Trigger-focused
internal tests move with the package; fixture-bound tests stay.
Mechanical follow-up to the SDK move on the backend branch; also
documents the strict response body rules in the consumer contract.
The options still work and stay documented in the setup guide; hiding
them keeps experimental surface out of coder server --help and the CLI
reference.
A denied tool call folded the consumer's model_context into the
synthetic tool result, which persists with both-audiences visibility,
so the context leaked to REST, SSE, shared transcripts, and the UI.
The context now becomes a model-only transcript row, matching the
allow path; prompt conversion already keeps tool results adjacent to
their assistant calls with the context after them.
Prompt conversion drops whitespace-only text parts, so a stop hook
returning a blank model_context consumed the one allowed continuation
and started a paid generation that nudged the model with nothing. The
continuation decision and the persisted hook-context row now both test
trimmed content.
Consumers now compare the aud claim against a configured audience instead
of one derived from the request, so the forwarded-header guidance no
longer applies and the reference consumer needs --audience.
…ails

A failed user_prompt_submit dispatch during subagent spawn admission
returned before the commit, discarding the whole step. A sibling tool in
the same batch had already run and mutated the workspace, so its result
was lost and the tool would be re-executed on retry.

The step now commits and the turn fails afterwards through the existing
PostCommitError path, which runs CommitStep and FinishError atomically.
Spawn admission takes precedence over a concurrent post_tool_use failure
because it is the causal root; the other error is logged. post_tool_use
no longer fires for a refused spawn result, since that tool never ran.
…eneration

The manual-compaction contract claimed the chat always returns to
waiting with no assistant follow-up. The generation decision reads
user-visible history, so a post_compact effect that commits a
user-visible message leaves the history incomplete and the turn
continues. A model-only effect reaches the model without resuming.

No behavior change. TestManualCompactionPostCompactEffects pins both
sides of the distinction, and the existing hooks-disabled manual
compaction test is scoped to that case.
Model-authored tool arguments reach a pre_tool_use consumer as raw JSON
with key spelling preserved, but built-in tools decode the same bytes
with encoding/json, which matches struct fields case-insensitively and
keeps the last match. A policy reading "path" could therefore authorize
one value while the tool executed another, and a lone case variant such
as {"PATH":"/secret"} was invisible to the policy entirely.

Validate a built-in tool's raw input against its schema before the
dispatch and reject input that repeats a key or spells a schema property
with different capitalization. Rejected calls produce a synthetic error
result the model can retry, and unambiguous calls in the same batch still
run. Dynamic and MCP inputs are excluded because the client and the
workspace agent execute those calls rather than coderd. A consumer
authored input_override is rechecked after the dispatch and fails the
turn closed, because the model cannot correct it.

Two paths escaped a schema-only check. Execution resolves a deprecated
tool name to its canonical tool, so the gate resolves aliases before
looking the tool up. The edit_files decoder also accepts "search" and
"replace", which its schema does not advertise, so those aliases are now
matched exactly and their case variants are ignored.
Explain why a hook policy and a built-in tool can read the same JSON
differently, and state that Coder rejects duplicate keys and case
variants of schema properties before dispatching pre_tool_use. Note the
tools this check does not cover, the edit_files aliases a policy must
also inspect, and correct the reference server's startup output.
pre_tool_use ran after the assistant message was already committed, so an
input_override had to rewrite stored content in place. Message content is
immutable, and the rewrite was the only reason the mutation primitive existed.

Dispatch pre_tool_use for the calls the provider produced before building the
commit instead. The step is persisted once, carrying the input the tool runs
with, and synthetic denials commit alongside it. Execution consumes the
admitted call from history rather than dispatching a second decision, so each
call is decided exactly once.

Clients stream the model's proposed input while the dispatch is in flight and
converge on the stored input when the message commits, which the chat store
already does by clearing stream state on an assistant message.
A hook payload carries tool input as JSON, so a call whose input does not
parse cannot be marshaled into the request. The dispatch failed as a protocol
error and ended the turn, even though the tool decoder would have reported an
ordinary, retryable input error.

Reject such calls where ambiguous input is already rejected, producing a
synthetic tool error the model can correct. Without hooks configured the tool
decoder still reports the failure, so behavior there is unchanged.
…he batch

The duplicate-ID check ran inside the preflight dispatch, which receives the
batch after ambiguous and invalid calls are removed. When a model repeated a
tool-use ID and one of the twins was filtered out, the collision went
undetected: the surviving call was authorized under an ID the synthetic result
also carried, so an override keyed by that ID applied to both call blocks and
the decision could resolve the wrong one.

Check the complete pending batch before partitioning, and export the check so
the ordering is explicit at the call site.
A hook denial wrote a bare Response with no kind, so clients could not tell a
policy decision apart from a generic failure and titled it 'Request failed'.
Return a structured 403 carrying hook_denied, mirroring the dispatch-failure
response that already carries its own kind.

Also correct the stale-input window in the hooks docs: a batch dispatches
sequentially before the assistant row commits, so the window scales with the
number of tool calls rather than a single hook timeout.
…light

Usage limit enforcement was removed from chatd in #27535, so SendMessage
and EditMessage no longer return UsageLimitExceededError and those two
cases could never reject a prompt. The invalid-model-config and
queue-full cases still cover the invariant that a rejected prompt
dispatches no hook.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants