Budibase/budibase · error · HTTPError

AI message content must be a string

Error message

AI message content must be a string

What it means

toPrompt converts an internal ModelMessage[] into prompt instructions and only supports string content. If any message's content is an array or other structured form (multimodal/parts), it throws HTTPError 422 because table generation prompts are text-only.

Source

Thrown at packages/pro/src/ai/generators/tableGeneration.ts:180

  }

  private getErrorMessage(err: unknown): string {
    if (!err || typeof err !== "object") {
      return String(err)
    }
    const error = err as Record<string, unknown>
    return typeof error.message === "string" ? error.message : String(err)
  }

  private toPrompt(messages: Message[]): {
    instructions?: string
    messages: ModelMessage[]
  } {
    const instructions: string[] = []
    const modelMessages: ModelMessage[] = []
    for (const message of messages) {
      if (typeof message.content !== "string") {
        throw new HTTPError("AI message content must be a string", 422)
      }
      if (message.role === "system") {
        instructions.push(message.content)
      } else {
        modelMessages.push({
          role: message.role,
          content: message.content,
        } as ModelMessage)
      }
    }
    return {
      instructions: instructions.join("\n\n") || undefined,
      messages: modelMessages,
    }
  }
}

View on GitHub (pinned to a81a902e9a)

Solutions

  1. Flatten message content to a string before calling generation: join text parts (part.text) into one string.
  2. Strip or convert non-text content (images/tool calls) from the message history.
  3. Normalize messages at the boundary where they are produced so all content fields are strings.
  4. Update the generator to handle structured content if multimodal prompts are genuinely needed.

Example fix

// before
const msgs = history.map(m => ({ role: m.role, content: m.content })) // content may be array
// after
const msgs = history.map(m => ({
  role: m.role,
  content: typeof m.content === "string"
    ? m.content
    : m.content.map(p => p.text).join("\n"),
}))
Defensive patterns

Strategy: validation

Validate before calling

const allStrings = messages.every(m => typeof m.content === "string")
if (!allStrings) throw new Error("Flatten multimodal message content to strings first")

Type guard

function isTextMessage(m: ModelMessage): m is ModelMessage & { content: string } {
  return typeof m.content === "string"
}

Try / catch

try {
  return await generate(req)
} catch (e) {
  if (e.status === 422 && e.message.includes("content must be a string")) {
    return generate(req.map(flattenContent))
  }
  throw e
}

Prevention

When it happens

Trigger: Passing messages whose content is not a plain string (e.g. content arrays like [{type:"text",...}] from multimodal chat history or another AI pipeline) into the table generation flow, reached via the result/generate path.

Common situations: Reusing conversation history built for vision/multimodal models, piping messages from a different LLM SDK that structures content as parts, programmatic API usage constructing ModelMessage objects manually.

Related errors


AI-assisted analysis of Budibase/budibase@a81a902e9a (2026-08-29). Data as JSON: /api/errors/aaa8d98d407878ea. Report an issue: GitHub.