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:
…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:
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:
This stops a stray poll field from breaking a perfectly valid send.
Proposed upstream changes
A) Schema: split responsibilities by action
-
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.
-
message.poll
- poll-related fields should live exclusively on the
poll action schema.
send should not share these fields.
-
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"
-
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
}
-
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.
Summary
message.sendschema 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:action="send"causePoll fields require action "poll"errors.components.modalskeletons causecomponents.modal.fields must be a non-empty arrayerrors on ordinary sends.Environment
messagetool (action="send")sub2api-claude/claude-opus-4-6(behaves conservatively; tends not to overfill optional 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:
What actually happened
There are two closely related failure modes.
1) Poll fields pollute
action="send"In some environments,
message.sendschema 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:
Result: a perfectly reasonable
send + filePathcall is rejected as "you must use action=poll" even though no one explicitly asked for a poll.2) Discord
components.modalpollutes sendFor Discord, the schema exposes a full
componentsobject includingcomponents.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.modalexists (even as an empty skeleton), the runtime treats it as "modal is enabled" and immediately requiresfieldsto be a non-empty array. A common GPT-generated payload triggers the error:…even though the human only wanted to send a docx.
Why Opus seems "fine" while GPT breaks more often
Because the tool schema currently exposes poll + full
components+components.modalon genericsend, 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)
1) Runtime: ignore empty modal skeletons
In the compiled
send-*.jsandchannels/plugins/actions/discord.jsbundles, we changed:…to something equivalent to:
Effect:
components.modalskeleton (no fields orfields: []), it’s treated as "modal not enabled" instead of a hard error.2) Schema: hide
components.modalon the generic send schemaIn the dist bundles that define the components schema (e.g.
reply-*.js,pi-embedded-*.js,compact-*.js),components.modalappears roughly as:For a local hotfix we changed:
to:
and ensured that for generic
message.sendwe do not setincludeComponentsby default when building the tool schema.Effect:
sendaction schema no longer invites the model to fillcomponents.modal.sendschema.3) Poll schema/guard hotfix
Similarly, we locally removed:
poll fields from the send schema.
the send-side guard:
This stops a stray poll field from breaking a perfectly valid send.
Proposed upstream changes
A) Schema: split responsibilities by action
message.sendmedia/filePath/pathsilent,bestEffort,replyTo, etc.If there is a use case for "send with components" on this action, it would be better to:
sendWithComponents) whose purpose is clear and whose schema is narrower.message.pollpollaction schema.sendshould not share these fields.Discord components/modal
components.modalon the globalsendschema for all channels and models.B) Runtime: treat empty advanced-field skeletons as "not enabled"
Modal
Keep the stricter validation for real modals (non-empty fields), but treat missing/empty
components.modalas "modal not enabled".The pattern used in the local hotfix is a good template:
Poll
pollaction handler.C) Media allowlist: behavior is correct, but worth documenting
This part isn’t broken, but it’s a key piece of context:
localRootsare derived fromstateDirviagetDefaultMediaLocalRoots()and include:stateDir/media,stateDir/agents,stateDir/workspace,stateDir/sandboxeslocalRoots === undefined(i.e. default roots), there is an explicit hardening step that rejectsworkspace-*under that state dir to prevent accidental exfiltration of per-agent workspaces.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:
getDefaultMediaLocalRoots()andgetAgentScopedMediaLocalRoots();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:
sendschema.Splitting advanced fields out of the generic send schema, and making runtime validation robust to empty skeletons, will make
message.sendmuch more reliable across models and channels, while still keeping poll and Discord components/modal fully supported in the places where they’re explicitly used.