Skip to content

message.send schema overexposes poll/components/modal causing GPT auto-population breakages #43015

Description

@charzhou

Summary

message.send schema currently overexposes advanced fields (poll + Discord components/modal) on the generic send action. GPT-family models tend to auto-populate these optional fields even when the user only wants to send a simple message or attachment. Combined with strict runtime validation that triggers on mere field presence (rather than explicit opt-in), this leads to fragile behavior:

  • poll fields accidentally attached to action="send" cause Poll fields require action "poll" errors.
  • empty Discord components.modal skeletons cause components.modal.fields must be a non-empty array errors on ordinary sends.

Environment

  • OpenClaw: 2026.3.8 (3caab92)
  • Channel: Discord (also saw poll-related behavior on Feishu)
  • Tools: message tool (action="send")
  • Models involved:
    • sub2api-claude/claude-opus-4-6 (behaves conservatively; tends not to overfill optional fields)
    • GPT-5.x family (more aggressively follows tool schemas and fills optional object/array fields)

What I expected

For a simple "send a message with a local attachment" request, I expect:

{
  "action": "send",
  "channel": "discord",
  "target": "channel:...",
  "message": "...",
  "filePath": "/path/to/file.docx"
}

…to be treated as a plain send:

  • send the text
  • attach the local file (subject to media allowlist)
  • no poll, no Discord components, no modal semantics unless explicitly requested.

What actually happened

There are two closely related failure modes.

1) Poll fields pollute action="send"

In some environments, message.send schema includes poll fields alongside ordinary send parameters. With GPT-style models in the loop, the model sometimes auto-fills poll-related fields even when the user/tool never intended a poll.

Runtime then has a guard like:

if (action === "send" && hasPollCreationParams(params)) {
  throw new Error('Poll fields require action "poll"; use action "poll" instead of "send".');
}

Result: a perfectly reasonable send + filePath call is rejected as "you must use action=poll" even though no one explicitly asked for a poll.

2) Discord components.modal pollutes send

For Discord, the schema exposes a full components object including components.modal. GPT-family models often respond to this by constructing a full skeleton:

{
  "components": {
    "blocks": [],
    "container": { "accentColor": "", "spoiler": false },
    "modal": {
      "fields": [],
      "title": "",
      "triggerLabel": "",
      "triggerStyle": "primary"
    },
    "reusable": false,
    "text": ""
  }
}
``

The runtime parser for Discord components looks roughly like:

```ts
export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null {
  if (raw === undefined || raw === null) return null;
  const obj = requireObject(raw, "components");
  const blocksRaw = obj.blocks;
  const blocks = Array.isArray(blocksRaw)
    ? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
    : undefined;
  const modalRaw = obj.modal;
  const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
  let modal: DiscordModalSpec | undefined;
  if (modalRaw !== undefined) {
    const modalObj = requireObject(modalRaw, "components.modal");
    const fieldsRaw = modalObj.fields;
    if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) {
      throw new Error("components.modal.fields must be a non-empty array");
    }
    if (fieldsRaw.length > 5) {
      throw new Error("components.modal.fields supports up to 5 inputs");
    }
    const fields = fieldsRaw.map((entry, idx) =>
      parseModalField(entry, `components.modal.fields[${idx}]`, idx),
    );
    modal = {
      title: readString(modalObj.title, "components.modal.title"),
      triggerLabel: readOptionalString(modalObj.triggerLabel),
      triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
      fields,
    };
  }
  return { text: readOptionalString(obj.text), reusable, container: ..., blocks, modal };
}

So as soon as components.modal exists (even as an empty skeleton), the runtime treats it as "modal is enabled" and immediately requires fields to be a non-empty array. A common GPT-generated payload triggers the error:

components.modal.fields must be a non-empty array

…even though the human only wanted to send a docx.

Why Opus seems "fine" while GPT breaks more often

  • Opus tends to be conservative: it usually only fills fields that are clearly needed for the user’s intent.
  • GPT-family models are more eager to "complete" object structures described by the schema, especially nested objects/arrays.

Because the tool schema currently exposes poll + full components + components.modal on generic send, GPT ends up populating fields that nobody asked for, and the runtime treats those as hard opt-in instead of soft optional.

This isn't really about one model being "smart" or "dumb"; it's the schema design amplifying different model behaviors.

Current local hotfixes (for reference)

These are dist-level hotfixes applied on a single machine. They’re not proposed as the final upstream patch, but they illustrate the direction that seems to work well.

1) Runtime: ignore empty modal skeletons

In the compiled send-*.js and channels/plugins/actions/discord.js bundles, we changed:

if (modalRaw !== void 0) {
  const modalObj = requireObject(modalRaw, "components.modal");
  const fieldsRaw = modalObj.fields;
  if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0)
    throw new Error("components.modal.fields must be a non-empty array");
  if (fieldsRaw.length > 5)
    throw new Error("components.modal.fields supports up to 5 inputs");
  const fields = fieldsRaw.map((entry, idx) => parseModalField(entry, `components.modal.fields[${idx}]`, idx));
  modal = { title, triggerLabel, triggerStyle, fields };
}

…to something equivalent to:

if (modalRaw !== void 0) {
  const modalObj = requireObject(modalRaw, "components.modal");
  const fieldsRaw = modalObj.fields;
  const hasFields = Array.isArray(fieldsRaw) && fieldsRaw.length > 0;
  if (hasFields) {
    if (fieldsRaw.length > 5)
      throw new Error("components.modal.fields supports up to 5 inputs");
    const fields = fieldsRaw.map((entry, idx) =>
      parseModalField(entry, `components.modal.fields[${idx}]`, idx),
    );
    modal = { title, triggerLabel, triggerStyle, fields };
  }
}

Effect:

  • If the model sends an empty components.modal skeleton (no fields or fields: []), it’s treated as "modal not enabled" instead of a hard error.
  • Only when actual fields are present do we run the strict modal validation.

2) Schema: hide components.modal on the generic send schema

In the dist bundles that define the components schema (e.g. reply-*.js, pi-embedded-*.js, compact-*.js), components.modal appears roughly as:

const discordComponentMessageSchema = Type.Object({
  text: Type.Optional(Type.String()),
  reusable: Type.Optional(Type.Boolean(...)),
  container: ..., // accentColor, spoiler
  blocks: ...,    // component blocks
  modal: Type.Optional(discordComponentModalSchema),
}, {...});

const props = {
  ...,
  components: Type.Optional(discordComponentMessageSchema),
};

For a local hotfix we changed:

modal: Type.Optional(discordComponentModalSchema)

to:

modal: Type.Optional(Type.Never())

and ensured that for generic message.send we do not set includeComponents by default when building the tool schema.

Effect:

  • The generic send action schema no longer invites the model to fill components.modal.
  • Advanced Discord components/modal usage can still be supported via more specialized actions or narrower schemas, but they don’t clutter the default send schema.

3) Poll schema/guard hotfix

Similarly, we locally removed:

  • poll fields from the send schema.

  • the send-side guard:

    if (action === "send" && hasPollCreationParams(params)) {
      throw new Error("Poll fields require action \"poll\"; use action \"poll\" instead of \"send\".");
    }

This stops a stray poll field from breaking a perfectly valid send.

Proposed upstream changes

A) Schema: split responsibilities by action

  1. message.send

    • Default schema should only expose the fields needed for ordinary sends:
      • target/ channel / accountId
      • message text
      • media / filePath / path
      • optional flags like silent, bestEffort, replyTo, etc.
    • It should not expose poll creation fields.
    • It should not expose Discord components/modal by default.

    If there is a use case for "send with components" on this action, it would be better to:

    • either gate it behind a separate boolean flag and a nested schema, or
    • create a more specialized action (sendWithComponents) whose purpose is clear and whose schema is narrower.
  2. message.poll

    • poll-related fields should live exclusively on the poll action schema.
    • send should not share these fields.
  3. Discord components/modal

    • Expose these only on:
      • a dedicated "Discord components" tool or action, or
      • an explicitly-marked advanced-mode schema where the caller knows they’re working with Discord components.
    • Do not place components.modal on the global send schema for all channels and models.

B) Runtime: treat empty advanced-field skeletons as "not enabled"

  1. Modal

    • Keep the stricter validation for real modals (non-empty fields), but treat missing/empty components.modal as "modal not enabled".

    • The pattern used in the local hotfix is a good template:

      const hasFields = Array.isArray(fieldsRaw) && fieldsRaw.length > 0;
      if (!hasFields) {
        // ignore modal
      }
  2. Poll

    • Avoid send-side guards that treat any presence of poll-like fields as a hard error.
    • Instead, enforce poll invariants inside the poll action handler.

C) Media allowlist: behavior is correct, but worth documenting

This part isn’t broken, but it’s a key piece of context:

  • Default localRoots are derived from stateDir via getDefaultMediaLocalRoots() and include:
    • openclaw tmp dir
    • stateDir/media, stateDir/agents, stateDir/workspace, stateDir/sandboxes
  • When localRoots === undefined (i.e. default roots), there is an explicit hardening step that rejects workspace-* under that state dir to prevent accidental exfiltration of per-agent workspaces.
  • For agent-initiated sends, getAgentScopedMediaLocalRoots(cfg, agentId) extends the roots to include the current agent’s workspace dir, which is why a "simple send + filePath" for the current agent workspace can succeed.

From the outside, this can look inconsistent when you manually construct unusual message params and accidentally bypass the agent-scoped media roots. It would help to:

  • document the intended behavior of getDefaultMediaLocalRoots() and getAgentScopedMediaLocalRoots();
  • possibly provide a diagnostic helper (openclaw media roots --agent coder) to inspect effective roots.

Why this matters

In multi-model environments (Opus + GPT-family + others), tool schemas need to be designed for the most schema-driven models, not just the most conservative ones. Right now:

  • Opus tends to "do the right thing" because it doesn’t eagerly fill optional fields.
  • GPT-family models follow the schema more eagerly and expose design flaws in the schema + runtime coupling:
    • optional fields that are treated as hard opt-in at runtime,
    • and advanced features bolted onto the generic send schema.

Splitting advanced fields out of the generic send schema, and making runtime validation robust to empty skeletons, will make message.send much more reliable across models and channels, while still keeping poll and Discord components/modal fully supported in the places where they’re explicitly used.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions