Progress Bar Is Not an API

When a CLI becomes useful, someone eventually tries to automate it.

That is where a progress bar can quietly become a problem.

For a person, this kind of output is helpful:

Translating markdown files  12/40  30%  docs/intro.md

It tells me that the command is alive, how far it has moved, and which file it is working on.

But when another system starts reading that same output, the progress bar stops being only a user interface. It becomes an accidental contract.

That was the real problem behind one of the changes in Co-op Translator v0.20.0. The release added a Rich-powered CLI progress UI, but it also added structured translation events.

At first, those may look like two separate improvements. They are really two surfaces for the same state: Rich output gives a person something readable, while structured events give integrations something stable.

This article is about three things:

First, why console output is tempting to parse.

Second, how Co-op Translator separated the Rich UI from the event stream.

Third, why that separation matters for CLI, Python API, MCP, and product integrations such as Localizeflow.

The problem appears when logs become state

Console output is written for people.

When I run a translation command, I want a quick answer to a few practical questions:

  • Is the command still running?
  • Which stage is active?
  • Which file is being processed?
  • How much work is left?
  • Did anything fail?

A progress bar is good for that. It compresses the state of the run into something I can scan quickly.

But a product integration needs a different kind of information.

Imagine Localizeflow running Co-op Translator as part of a larger workflow. It does not only need to know that text was printed. It needs durable state:

  • Which translation job started
  • Which target language is active
  • Which stage is running
  • Which file completed
  • Which file failed
  • How many items are done
  • Whether the run succeeded

If all of that only exists inside console text, the integration has to parse human language.

That is fragile because the text was never meant to be a contract.

A better UI can break a parser

The strange part is that an improvement for humans can become a breaking change for machines.

Suppose an integration looks for this text:

Translating markdown files: 12/40

Now suppose the CLI becomes nicer. The same information may move into a Rich table, a progress bar, a status panel, or a shorter label.

For a person, that is probably better.

For a parser, it can be a failure.

Even a small wording change can be risky:

Retranslating outdated markdowns

Later, the label becomes clearer:

Retranslating outdated markdown files

That change should be harmless. It is only display text.

But if another system depends on the exact label, the display improvement becomes an integration breaking change.

That made the boundary easier to see. A progress bar can explain the run to a person, but it is too changeable to be the surface another program depends on.

Once that was clear, the fix was not to make the progress bar more parseable. The fix was to stop asking the progress bar to do two jobs.

The fix was to split the output

In v0.20.0, progress information is created once and then sent to two different surfaces.

The first surface is the human renderer.

In the CLI, Rich can turn the current translation state into headers, tables, progress bars, and status lines. That output should be pleasant to read. It should be allowed to improve over time.

The second surface is the system event stream.

External integrations should not parse console output. They should consume events with a stable schema.

An event can describe the same progress in a form that another system can trust:

{
  "schema": "co-op.translation.event.v1",
  "type": "stage_progress",
  "stage_key": "translating_markdown_files",
  "stage_label": "Translating markdown files",
  "language": "ko",
  "current_path": "docs/intro.md",
  "completed": 12,
  "total": 40,
  "progress": 30
}

The progress bar and the event may come from the same internal state, but they are shaped for different audiences. The UI can use friendly labels and richer layout, while the event keeps stable fields that integrations can store and compare.

Separate labels from keys

One of the most important details is the difference between stage_label and stage_key.

stage_label is display text:

{
  "stage_label": "Translating markdown files"
}

That value can change if a better phrase makes the CLI easier to understand.

stage_key is the stable value that integrations should depend on:

{
  "stage_key": "translating_markdown_files"
}

External systems should use the key, not the label. The label can be edited for clarity, while the key is the value the contract promises to keep stable.

This looks like a small distinction, but it protects both sides. The CLI can keep getting clearer, and integrations do not have to fear every wording change.

Events should describe the workflow

A useful event stream should not only report a percentage.

Progress is not just a number. It is a workflow moving through states.

For Co-op Translator, that workflow can be represented with events such as:

run_started
estimate_ready
stage_started
stage_progress
file_completed
run_completed

Warnings and failures can use events such as:

warning
file_failed
run_failed

This makes integration code much simpler.

A dashboard can create a job when it receives run_started. It can show token estimates after estimate_ready. It can update the visible progress after stage_progress. It can mark a job as complete after run_completed.

The dashboard does not need to understand Co-op Translator's console sentences.

It only needs to understand the event contract.

One contract can serve several interfaces

Co-op Translator is not only a CLI.

It also has a Python API and an MCP server. That means the event contract cannot belong to one terminal UI.

In the CLI, a person can use the Rich progress UI. If another system needs machine-readable output, it can write NDJSON events:

translate -l "ko ja" -md --json-events progress.ndjson

In the Python API, callers can receive the same kind of events through a callback:

from co_op_translator.api import run_translation


def on_event(event):
    record_translation_event(job_id, event.to_dict())


run_translation(
    language_codes="ko ja",
    root_dir=".",
    markdown=True,
    progress_callback=on_event,
)

In MCP, run_translation can return events in the tool result payload. Agents and host applications can use those events to understand what happened without scraping terminal output.

The interfaces are different, but the contract is shared:

  • CLI: Rich renderer and NDJSON event file
  • Python API: progress_callback
  • MCP: event payload in the tool result

That is the practical value of separating display from events. The UI can evolve without destabilizing API and MCP integrations.

What changes for Localizeflow

The difference becomes clearer from the perspective of a product such as Localizeflow.

In the fragile model, Localizeflow receives console text and tries to extract meaning from it:

Done: Translated README.md to Korean.

From that sentence, it has to infer the file name, language, and completion state.

With structured events, it can store a durable fact instead:

{
  "type": "file_completed",
  "language": "ko",
  "current_path": "README.md"
}

That event is easy to append to a database. Current job status can be materialized from the event stream. Logs can still exist, but they are supporting context, not the source of truth for product state. In that model, logs remain useful for someone investigating a run, while events carry the facts Localizeflow depends on.

Rich still matters

Separating events from the UI does not make the UI less important.

It makes the UI easier to improve.

A human-facing CLI should be clear and pleasant. It should show the command, target language, estimate, current stage, current file, and failures in a way that is easy to scan.

Rich is useful for that:

  • Headers can group run information
  • Tables can organize estimates and stage progress
  • Progress bars can make long jobs easier to follow
  • GitHub Actions logs can remain reasonably readable

But Rich output should still be allowed to change when a clearer display helps the person using the CLI.

In this design, Rich renders the human-facing view, while the versioned event schema carries the machine-readable state.

That separation is what makes both surfaces better.

The principle

The lesson from this work is simple:

Logs are for humans. Events are for systems.

People can read sentences and infer context. A small wording change is usually fine.

Systems need stable keys, schema versions, event types, and typed fields.

So when designing progress output, the first question should not be "How do I make this text easier to parse?"

The better question is:

Who is going to depend on this output?

If a person will read it, make the UI clear.

If another system will depend on it, provide versioned structured events.

Progress bars still matter. They create a better CLI experience.

But when another system will depend on progress, the stable surface should be a versioned event stream.

Comments