Skip to content

docs(hooks): UserPromptSubmit hook payload prompt is a ContentPart[] array, not the submitted text string #917

Description

@ken-jo

What version of Kimi Code is running?

N/A — this is a documentation-accuracy report about the hook payload contract, verified against the current main source rather than a single CLI build. Reproducible on any version where UserPromptSubmit hooks exist.

Which open platform/subscription were you using?

N/A (documentation / hook-protocol issue, not tied to a login platform).

What issue are you seeing?

The Hooks docs lead a hook author to believe the UserPromptSubmit payload field prompt is the submitted text string, but in the code it is the raw ContentPart[] array. A hook that reads payload.prompt expecting a string gets an array of content-part objects (e.g. [{ "type": "text", "text": "…" }]), so naive string handling silently produces wrong/empty results.

Where the docs mislead (docs/en/customization/hooks.md):

  • The Event Reference table (line 101) describes UserPromptSubmit only as: "Matcher matches: The text submitted by the user … Triggered when the user sends a message; returned text is appended to context …". This describes the matcher subject, not the JSON payload field — and there is no column or note about the payload shape.
  • The Event Data Format section (lines 57–69) shows the base payload (hook_event_name, session_id, cwd) and states "Specific events will also include additional fields … see the event reference below. All field names use snake_case." But the Event Reference table has no additional-fields column, so prompt is never documented at all — its existence, name, or shape.
  • The only worked payload example (lines 130–146, the block-dangerous-bash.mjs snippet) reads payload.tool_input?.command, reinforcing a "fields are scalars/plain objects" mental model. Nothing signals that prompt is an array.

What the code actually sends (current main):

  • packages/agent-core/src/agent/turn/index.ts:561,568-572applyUserPromptHook(turnId, input: readonly ContentPart[], …) triggers the hook with:
    this.agent.hooks?.trigger('UserPromptSubmit', {
      matcherValue: input,
      signal,
      inputData: { prompt: input },   // input is readonly ContentPart[]
    });
    So payload.prompt is the raw ContentPart[].
  • packages/agent-core/src/session/hooks/engine.ts:72-78 — the engine flattens matcherValue to text via matcherValueText(...), but spreads ...args.inputData into the payload verbatim. The flattening applies only to the matcher, not to the payload field.
  • packages/agent-core/src/session/hooks/engine.ts:140-147matcherValueText is what joins the text parts with a space (this is what the matcher regex sees). The payload prompt does not go through this.

Net effect: the matcher matches against the joined text (consistent with the doc's "The text submitted by the user"), but the payload prompt field a hook reads from stdin is the array, which the doc never states.

What steps can reproduce the bug?

  1. Add a UserPromptSubmit hook to ~/.kimi-code/config.toml:
    [[hooks]]
    event = "UserPromptSubmit"
    command = "node ~/.kimi-code/hooks/echo-prompt.mjs"
  2. echo-prompt.mjs:
    let input = '';
    process.stdin.on('data', (c) => (input += c));
    process.stdin.on('end', () => {
      const payload = JSON.parse(input);
      // Doc-implied expectation: prompt is the submitted text string
      console.error('typeof prompt =', typeof payload.prompt);
      console.error('prompt =', JSON.stringify(payload.prompt));
    });
  3. Start a session and submit a message such as hello.

Observed: typeof prompt = object and prompt = [{"type":"text","text":"hello"}] (a ContentPart[]), not the string "hello". Any hook doing payload.prompt.includes(...), payload.prompt.toLowerCase(), regex on payload.prompt, etc. silently breaks, and typeof payload.prompt === 'string' ? payload.prompt : '' collapses the real prompt to ''.

We hit this while building a cross-platform hook/MCP adapter: our UserPromptSubmit handler assumed prompt was a string (matching the doc's "text submitted by the user"), and the payload silently degraded to empty against the real array. The matcher behaved as documented, which made the payload-vs-matcher distinction easy to miss.

What is the expected behavior?

The docs should state the payload contract for UserPromptSubmit explicitly:

  • prompt is a ContentPart[] array (e.g. [{ "type": "text", "text": "…" }]), not a plain string.
  • The matcher is applied to the joined text of those parts (per matcherValueText), which is why the Event Reference says "The text submitted by the user" — that describes the matcher, not the payload field.
  • A short snippet showing how to recover the text, e.g. payload.prompt.filter(p => p.type === 'text').map(p => p.text).join(' ').

Optionally (separate, smaller ergonomics enhancement — happy to open a dedicated feature-request issue first per CONTRIBUTING): consider also passing a flattened prompt_text string in the payload so hook authors who only need the text don't each reimplement the join. The doc clarification stands on its own regardless.

I'm happy to send a documentation PR for the clarification (docs-only, no changeset, Conventional Commit title) — see the proposed diff in the linked PR if/when a maintainer is open to it. Related but distinct existing reports: #897 (hiding UserPromptSubmit hook output in the TUI) and #345 (PreToolUse updatedInput).

Additional information

Line numbers cited against the repository main branch at the time of filing (docs/en/customization/hooks.md, packages/agent-core/src/agent/turn/index.ts, packages/agent-core/src/session/hooks/engine.ts). The Hooks feature is documented as Beta, so pinning the payload contract now should be low-risk and high-value for early hook authors.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions