The Wall of JSON
Friday works. Claude is responding. Scheduled tasks fire. Tool calls complete. You open Telegram to check on a long-running job and you see this:
🔧 Bash
{
"command": "find . -name '*.ts' -not -path '*/node_modules/*'",
"description": "Find all TypeScript files"
}
🔧 Read
{
"file_path": "/home/friday/Code/friday.assertchris.dev/src/acp/session.ts",
"limit": 200
}
🔧 Bash
{
"command": "npm test",
"description": "Run the test suite"
}
It works. But on mobile, each line wraps and the braces stack up. When Claude chains ten tool calls in a row, it's a screen-length wall. You scroll past ten braced objects just to find out if anything finished.
That's the moment when "it works" and "I want to use this" start to diverge.
The accumulation loop in bridge.ts looks like this:
} else if (msg.type === 'tool_use') {
const inputPreview = JSON.stringify(msg.toolInput, null, 2).slice(0, 500);
this.#accumulatedText +=
(this.#accumulatedText ? '\n\n' : '') +
`🔧 ${String(msg.toolName)}\n${inputPreview}`;
textChanged = true;
}
This is from
src/telegram/bridge.ts
Every tool call is formatted with JSON.stringify sliced to 500 characters and appended to a growing #accumulatedText string. That string is sent – or edited in place – as a plain Telegram message with no parse mode.
The description field is right there in the input. The code ignores it and dumps the whole object.
On desktop it's tolerable. On mobile, by the end of anything complex, you have a message long enough that scrolling to the bottom feels like work.
Why HTML, Not MarkdownV2
We need a parse mode. Telegram supports two: MarkdownV2 and HTML.
MarkdownV2 looks natural to write – asterisks for bold, backticks for code. The problem is the escape list. Sixteen characters need escaping: _, *, [, ], (, ), ~, `, >, #, +, -, =, |, {, }, ., !. Any one of them appearing unescaped in a file path, shell command, or code snippet causes the API call to fail.
File paths fail. Shell commands fail. Any assistant response mentioning a version number, a bullet list, or a file extension fails. You'd need to escape Claude's output before sending it, and Claude's output is arbitrary text. There's no good answer to that.
HTML mode only requires escaping three characters:
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
This is from
src/telegram/bridge.ts
Three replacements. Every code path that assembles text runs it through escapeHtml and the send won't crash.
But the real reason HTML won was <blockquote expandable>. That tag – added in Telegram Bot API 7.0 – renders a collapsed, tappable section in the chat. Tap to expand. It's the only way to hide content in a message without sending a separate one. MarkdownV2 has no equivalent.
Collapsible tool call history requires <blockquote expandable>. <blockquote expandable> requires HTML mode. The decision made itself.
Segments Over Strings
HTML mode solved the escaping problem. Structure was next.
The accumulation model was a single string: every piece of output appended to #accumulatedText and sent as one blob. To add collapse behaviour, we'd need to locate previous tool call blocks inside that string and wrap them with <blockquote expandable> tags. String surgery. Fragile, full of edge cases around newlines and interleaved content.
The cleaner move was to replace the string with a typed list:
export type Segment = {
type: 'text' | 'tool_call' | 'error' | 'result';
content: string;
};
This is from
src/telegram/bridge.ts
The #accumulatedText = '' field became #segments: Segment[] = []. The polling loop, instead of concatenating strings, pushed a new segment for each message:
if (msg.type === 'assistant' && msg.content) {
this.#segments.push({ type: 'text', content: String(msg.content) });
textChanged = true;
} else if (msg.type === 'tool_use') {
this.#segments.push({
type: 'tool_call',
content: formatToolCall(
String(msg.toolName),
(msg.toolInput as Record<string, unknown>) ?? {},
this.#session.cwd,
),
});
textChanged = true;
}
This is from
src/telegram/bridge.ts
Each message type maps to a segment type. The content gets formatted at push time. buildMessage() then walks the list and decides how to render each one – a straightforward loop over structured data instead of a regex hunt through a string.
The refactor cost a bit more up front. The payoff was that the collapse logic became transparent. You can read buildMessage() and see exactly what it does.
The Collapse Logic
buildMessage() has one job: take a list of segments and produce a single HTML string for Telegram.
The collapse rule is simple. All tool calls except the most recent one get wrapped in <blockquote expandable>. The most recent tool call stays expanded – that's the one you're currently watching. Everything else is history you can tap into if you need it.
export function buildMessage(segments: Segment[]): string {
const parts: string[] = [];
let lastToolIndex = -1;
for (let i = segments.length - 1; i >= 0; i--) {
if (segments[i].type === 'tool_call') {
lastToolIndex = i;
break;
}
}
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const escaped = escapeHtml(seg.content);
if (seg.type === 'tool_call' && i < lastToolIndex) {
parts.push(`<blockquote expandable>${escaped}</blockquote>`);
} else {
parts.push(escaped);
}
}
return parts.join('\n\n');
}
This is from
src/telegram/bridge.ts
The first pass finds lastToolIndex – the index of the most recent tool call. The second pass walks all segments and applies the rule: if it's a tool call and it's not the last one, collapse it.
Text and error segments get HTML-escaped and rendered as-is. The latest tool call passes through unchanged too – it renders as a plain blockquote by default, visually consistent with the collapsed ones but expanded.
The message grows from the top down. Older tool calls collapse as new ones arrive. By the time Claude replies, the tool call history is tucked away above it.
Handling Overflow
Telegram messages have a hard limit of 4096 characters. The original bridge handled this by tail-truncating: if the string was too long, keep the last 4096 characters and append \n....
That cuts exactly the wrong end. The most recent content – the thing you most want to see – is at the bottom. Tail truncation removes it first.
With segments, there's a better option: drop the oldest collapsed tool calls. They're already hidden. You weren't reading them. Losing them costs nothing visible.
if (message.length > MAX_MESSAGE_LENGTH) {
while (message.length > MAX_MESSAGE_LENGTH && segments.length > 0) {
const oldest = segments.findIndex((s) => s.type === 'tool_call');
if (oldest === -1) break;
segments.splice(oldest, 1);
message = buildMessage(segments);
}
if (message.length > MAX_MESSAGE_LENGTH) {
message = message.slice(-MAX_MESSAGE_LENGTH + 4) + '\n...';
}
}
This is from
src/telegram/bridge.ts
The loop finds the oldest tool call segment and removes it, then rebuilds the message. It keeps going until the message fits or there are no tool call segments left. The tail truncation is still there as a last resort – if you've dropped every tool call and the assistant text alone is still over 4096 characters, there's no better option.
In practice, the last resort rarely fires. A complex task produces a lot of tool call segments. Dropping them one by one brings the message under the limit well before the assistant text is at risk.
The order matters too. findIndex returns the first match – the oldest tool call. Dropping from the front preserves the most recent history for as long as possible.
Cleaning the Data
Solving the structure problem made the messages readable. The data inside them was still noisy.
The most obvious case: file paths. Every Read and Edit call included the full absolute path to the file. On a server, that's something like /home/friday/Code/friday.assertchris.dev/src/telegram/bridge.ts. Long, repetitive, and it leaks the server's directory structure into every message.
A small helper strips the project root:
export function relativePath(absolutePath: string, cwd: string): string {
const prefix = cwd.endsWith('/') ? cwd : cwd + '/';
if (absolutePath.startsWith(prefix)) {
return absolutePath.slice(prefix.length);
}
return absolutePath;
}
This is from
src/telegram/tool-formatter.ts
Five lines. The result: src/telegram/bridge.ts instead of the full path. Fits on one line on mobile. Doesn't tell anyone where Claude is running.
The per-tool formatters live in a formatters map in tool-formatter.ts. Each entry is a tool name mapped to a function that takes the raw input and returns formatted HTML. The Bash formatter shows why the description field matters:
Bash(input) {
if (typeof input.description === 'string' && input.description) {
return `🔧 Bash\n<code>${escapeHtml(input.description)}</code>`;
}
if (typeof input.command === 'string' && input.command) {
const cmd =
input.command.length > 200
? input.command.slice(0, 200) + '…'
: input.command;
return `🔧 Bash\n<code>${escapeHtml(cmd)}</code>`;
}
return fallback('Bash', input);
},
This is from
src/telegram/tool-formatter.ts
If there's a description, use it. "Find all TypeScript files" is more useful than the find command. No description? Fall back to the command itself, truncated. Neither? Fall back to the JSON dump – the same output as before, but now only for tools that genuinely have no better representation.
Unknown tools hit fallback() and show the JSON preview. Everything else shows something readable at a glance.
It's the part of formatting work that compounds. You solve the structural problem first – segments, HTML, blockquotes – and then realise the data itself still needs attention. The structure gets you readable. The formatters get you useful.
Conclusion
A long Claude task in Telegram now looks like this: one message, with collapsed callouts for each finished tool call, the current one expanded, and assistant text below. The most recent action is always visible. Everything else is one tap away.
On mobile, where this matters most, the message is compact by default.
Permission requests changed too. Before the refactor, they used @grammyjs/parse-mode's FormattedString entities to render a JSON blob with code formatting. After, they use the same formatToolCall path as the main message:
Permission request:
🔧 Bash
run the test suite
/approve or /deny
You can read that in a second and act on it without parsing anything.
The session usage footer arrived in the same sprint – a line at the bottom of each message showing context window usage, fetched from Anthropic's API and cached. That information existed before; it surfaced only if you went looking. Knowing you're at 80% context changes whether you start a long task. Making it visible in every message turned it from a diagnostic into something ambient.
The lesson is that formatting is a product decision, not a polish pass. The wall of JSON was never wrong – it was correct output from a correct program. It worked. The gap was between "correct" and "something I'll actually open on my phone and check." That gap is small in principle and wide in practice.
It's also the kind of thing that's easy to defer indefinitely. The output is there. The task completed. Close enough. The reason to close the gap isn't correctness – it's that an assistant you don't want to check isn't an assistant. It's a process running in the background that you occasionally remember to query.
Friday should feel like something worth opening.