Fix Raw ** and ## in a Streaming FlutterFlow Chatbot
Your support bot answers, tokens fly in one at a time, and for a second or two the bubble reads:
**Refunds** are processed in ## 3 business days
Then the reply finishes and — snap — it all formats correctly: bold text, a heading, a clean list. Users only ever see the ugly in-between state. If you've searched flutterflow markdown streaming not formatting or flutterflow ai chat raw markdown symbols, this is the exact bug, and the fix is smaller than you think.
The Markdown widget isn't broken
The instinct is to blame the renderer. It's almost never the renderer. Two things are actually going on.
Cause 1: you're streaming into a plain Text widget. Lots of FlutterFlow builders show the live tokens in a Text (or a bound String) while streaming, then swap to a Markdown widget only on completion. A Text widget has no idea what ** means, so of course it shows the literal characters. If that's you, the fix is simply to render the partial buffer through a Markdown widget on every frame.
Cause 2 (the subtle one): half-open syntax. Even once you render Markdown every frame, you still get flashes of raw symbols. That's because a token-by-token SSE stream hands the parser incomplete markdown. The word **bold** doesn't arrive whole — it arrives as **, then **bo, then **bold, then finally **bold**. Until that closing ** lands, the buffer contains an unbalanced bold marker, and every conforming CommonMark parser renders a lone, unclosed ** as literal text. Same story for an open code fence (three backticks with no closer) or an unterminated `. This is the root of flutter streaming markdown incomplete tags and flutterflow chatbot bold not rendering.
So the real task is: render markdown token by token in Flutter without ever showing half-open syntax.
The fix: buffer and heal
Keep a running buffer of everything received so far. Before you hand each frame to the Markdown widget, run it through a tiny function that temporarily closes any dangling markers — a dangling ** gets a ** appended, an open code fence gets a closing fence, and so on. The heal is only for display; the underlying buffer stays raw. When the stream ends, the buffer is naturally balanced, so you render the complete text verbatim with no healing at all.
Here's the whole healer. It handles the three markers that cause 99% of the flicker in practice:
/// Closes markdown that the stream has opened but not yet closed, so a
/// partial buffer renders as formatted text instead of raw symbols.
/// The final, complete reply is already balanced and needs no healing.
String healMarkdown(String text) {
var out = text;
const fence = '`' * 3; // a fenced code-block marker
// 1. Inside an open code fence? Close it. An odd number of fence
// markers means the closing fence hasn't streamed in yet. Return
// early: inline markers inside code aren't parsed anyway.
if (fence.allMatches(out).length.isOdd) {
return '$out\n$fence';
}
// With fences balanced, each fence contributes an even count of
// backticks, so leftover backtick parity reflects inline code.
// 2. Unbalanced inline code `like this`
if ('`'.allMatches(out).length.isOdd) out += '`';
// 3. Unbalanced bold **like this**
if ('**'.allMatches(out).length.isOdd) out += '**';
return out;
}
A few things worth calling out. Fences are checked first and short-circuit — while you're mid code block, ** and single backticks are literal to the parser, so healing them would corrupt the code. And because a balanced pair of fences adds six backticks (an even number), the single-backtick parity check cleanly isolates genuine unclosed inline code. Headings (##) and list markers (-) are line-level: they render fine the moment the # or - prefix and its space arrive, so they don't need healing — the earlier flicker you saw from them was Cause 1, a Text widget swallowing the whole line.
Wiring it into the WidgetChat SSE stream
WidgetChat streams replies token-by-token from POST https://api.widgetchat.app/v1/chat/stream as Server-Sent Events — each line is a data: frame. No SDK required; a FlutterFlow Custom Action using the http package is enough. The action reads the stream, appends each token to the buffer, and pushes a healed string out on every frame via a callback (wire this to an App State String that your Markdown widget is bound to).
import 'dart:convert';
import 'package:http/http.dart' as http;
/// FlutterFlow Custom Action. Streams a WidgetChat reply and emits a
/// healed partial string on every token, then the final complete text.
Future streamWidgetChat(
String message,
String apiKey,
Future Function(String text) onUpdate,
) async {
final request = http.Request(
'POST',
Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
)
..headers['Authorization'] = 'Bearer $apiKey'
..headers['Content-Type'] = 'application/json'
..body = jsonEncode({'message': message});
final response = await http.Client().send(request);
final buffer = StringBuffer();
await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (!line.startsWith('data:')) continue;
final payload = line.substring(5).trim();
if (payload.isEmpty || payload == '[DONE]') continue;
// Decode the JSON frame and append its text delta. Adjust the key
// to match your stream; fall back to raw text if it isn't JSON.
String token;
try {
final frame = jsonDecode(payload);
token = (frame['token'] ?? frame['delta'] ?? '') as String;
} catch (_) {
token = payload;
}
buffer.write(token);
// Render the half-finished buffer, healed so nothing flashes raw.
await onUpdate(healMarkdown(buffer.toString()));
}
// Final frame: the buffer is balanced now — render it verbatim.
await onUpdate(buffer.toString());
}
That last line matters. Don't leave the healed version on screen — the temporary closers you appended aren't in the real reply. Emitting the raw, complete buffer as the final frame guarantees the finished message is byte-for-byte what the model actually sent.
Rendering the healed string
For the widget itself, note that Google discontinued flutter_markdown on 30 April 2025. The community-maintained drop-in successor is flutter_markdown_plus (1.0.12 at the time of writing); gpt_markdown is another solid option tuned for LLM output with tables and LaTeX. Bind your healed App State String to a MarkdownBody inside a FlutterFlow Custom Widget:
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
class ChatBubble extends StatelessWidget {
const ChatBubble({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return MarkdownBody(data: text, selectable: true);
}
}
MarkdownBody sizes to its content and drops straight into a chat bubble — no scroll view, no padding surprises.
Known edges
This healer is deliberately tiny. If your bot emits ** inside a code block as literal characters, strip fenced regions before the bold check to avoid a false positive. Single-asterisk italics are skipped on purpose so they never collide with **. In practice, bold, inline code, and fences cover the visible flicker; add markers only when you actually see them misbehave.
Ship it
Buffer, heal per frame, render the complete text on the final frame. That's the entire fix — a dozen lines standing between your users and a wall of raw asterisks.
WidgetChat handles the streaming SSE endpoint, the model, and the dashboard so you only write the display layer you see above. Try WidgetChat free and drop a live-formatting AI support chatbot into your Flutter or FlutterFlow app today.





Comments
Comments are coming soon. We'd love to hear your thoughts!