Post Length Overflow

Friday streams Claude's responses back to Telegram by editing a single message in place. As tokens arrive, the message grows. This works beautifully – until it doesn't.

Telegram has a hard limit: 4,096 characters per message. When a response approaches that ceiling, something has to give.

The code handles this by quietly deleting content. First it drops the oldest tool-call blocks. If that isn't enough, it slices characters off the front of the message and sticks a at the beginning.

What you get back looks complete but is missing its opening paragraphs. Code that lost its imports. Explanations that start mid-sentence.

You never see an error. You see a message that feels wrong – truncated, incomplete – with no indication that anything has been removed. The worst kind of data loss: the silent kind.

The buildMessage() function in message-builder.ts is responsible for rendering an array of segments – text blocks, tool calls, results – into Telegram-flavoured HTML. It also contains the safety valve. If the rendered output exceeds 4,096 characters, it progressively sheds content to fit:

if (message.length > maxLength) {
    while (message.length > maxLength && segments.length > 0) {
        const oldest = segments.findIndex((s) => s.type === 'tool_call');
        if (oldest === -1) break;
        segments.splice(oldest, 1);
        message = buildMessage(segments, reserveLength);
    }
    if (message.length > maxLength) {
        message = message.slice(-maxLength + 4) + '\n...';
    }
}

This is from src/telegram/message-builder.ts

Drop the oldest tool call. Rebuild. Still too long? Drop another. Still too long? Slice from the front and hope for the best. It keeps the bot from crashing with a Telegram API error. But it treats the character limit as a wall to squeeze through rather than a signal to open a new door.

Overflow Beats Compression

There's no reason to cram everything into one message. We can start a second – or third, or tenth. Instead of truncating harder, we can finalise the current post when it reaches a comfortable threshold and open a fresh message for the content that follows. Both messages stay intact. Nothing is lost.

This reframes the problem. The 4,096-character limit isn't a constraint on how much Friday can say – it's a constraint on how much fits in a single container. Containers are free. We just need more of them.

The 3,800-Character Split Point

The hard limit is 4,096, but we split at 3,800. That 296-character buffer exists for a reason.

The usage footer – session cost, duration, token counts – gets appended after the content is rendered. That's 100 to 200 characters depending on the numbers. A few more tokens might stream in between the threshold check and the next update tick. If we split too close to the hard limit, the old truncation logic still fires on edge cases. Splitting at 3,800 means it almost never runs.

The threshold lives in message-builder.ts alongside the hard limit it's protecting against:

const MAX_MESSAGE_LENGTH = 4096;

export const POST_SPLIT_THRESHOLD = 3800;

This is from src/telegram/message-builder.ts

We also expose it as an environment variable through loadTelegramConfig():

export function loadTelegramConfig(): TelegramConfig {
    return {
        postSplitThreshold: parseInt(
            process.env.TELEGRAM_POST_SPLIT_THRESHOLD ?? '3800',
            10
        ),
    };
}

This is from src/config.ts

This isn't because the threshold needs to change often. It's because the right number depends on factors – footer length, polling interval, segment burst size – that might shift as other parts of the system evolve. Making it configurable costs one function call and buys flexibility later.

Segment Offset Tracking

The bridge doesn't copy segments or maintain separate arrays per post. It keeps a single #postSegmentOffset index. When building a post, it slices the segment array from the offset. When a split fires, the offset advances to segments.length. The new post starts with only subsequent segments.

Here's the relevant state on the bridge:

#responseMessageId: number | null = null;
#postSegmentOffset = 0;

This is from src/telegram/bridge.ts

And here's how the poll loop uses them. The segment slice happens first – we take everything from the offset onward and render only that:

const allSegments = this.#renderer.segments;
const postSegments = allSegments.slice(this.#postSegmentOffset);
const segments: Segment[] =
    postSegments.length > 0
        ? postSegments
        : [{ type: 'text', content: '⏳ Thinking…' }];

This is from src/telegram/bridge.ts

When the segment slice is empty – the split just fired and no new content has arrived yet – the bridge falls back to a "⏳ Thinking…" placeholder. That guard already exists for the start of a fresh response. After a split, it means there's a brief moment where the new post shows the placeholder before content starts flowing. No new code needed.

The split decision comes right after the upsert:

if (
    html.length >= this.#postSplitThreshold &&
    postSegments.length > 0
) {
    this.#responseMessageId = null;
    this.#postSegmentOffset = allSegments.length;
}

This is from src/telegram/bridge.ts

Two lines of real work. Null the message ID so the next upsert creates a new message instead of editing the current one. Advance the offset so the new post starts fresh. The old post stays exactly as it was when the last edit landed.

Post-Upsert Timing

The split fires after the Telegram edit succeeds, not before. If we split first and the send fails – network blip, rate limit – the offset has already moved. Content falls into a crack between the old post and the new one.

By splitting after, the finalised post always contains exactly what was last rendered. The edit landed. Only then do we null the message ID and advance the offset.

The post briefly exceeds the threshold before the next tick fires the split. That's fine – 3,800 is far enough from 4,096 that the buffer absorbs it.

The old truncation logic in buildMessage() is still there too, guarding against a single segment that exceeds 4,096 characters in one burst. The overflow system handles normal growth. The truncation handles pathological bursts. Belt and suspenders.

Conclusion

Long responses flow across multiple Telegram messages. Each message is complete and readable. The first fills up to around 3,800 characters, gets finalised, and subsequent content appears in a fresh message below it. The process repeats as many times as needed.

The old truncation – the prefix, the lost opening paragraphs – is gone from normal operation. It still exists as a safety net, but the conditions that trigger it are rare enough that you'll probably never see it.