DEV Community

Cover image for I Added ACP to APX Without Adding a Second Brain
Manuel Bruña
Manuel Bruña

Posted on

I Added ACP to APX Without Adding a Second Brain

I Added ACP to APX Without Adding a Second Brain

The first time I looked at ACP support for APX, I felt the usual temptation: build a special path for the new protocol, give it its own session store, tune it for IDE behavior, and let it grow into a separate product-shaped thing.

That would have been a mistake.

The real lesson for me was simpler: ACP should be a thin adapter, not a second brain. If APX already has a daemon, a super-agent, and a stream of events that describes a turn, then the ACP surface should translate that work, not re-implement it.

That choice sounds small. It is not. It decides whether a new surface stays cheap to maintain or slowly becomes a fork with better branding.

What ACP is doing in APX

APX already knows how to talk to its own daemon. The ACP agent in src/interfaces/acp/index.js does not start a parallel reasoning engine. It opens a JSON-RPC 2.0 stream on stdio and maps ACP session calls onto the daemon's existing chat stream.

That is the important part.

A client calls session/new, session/prompt, or session/cancel. APX does the rest through the same runtime path it already uses elsewhere. The ACP layer is just a translator between one protocol and another.

In practice, that means:

  • initialize reports the ACP version and basic capabilities
  • authenticate is accepted as a no-op because daemon auth stays on the local bearer token
  • session/new resolves the project from the working directory
  • session/prompt streams the prompt into /projects/:pid/super-agent/chat/stream
  • session/cancel aborts the active turn

That is enough.

The protocol surface can be rich without becoming a second implementation. That distinction matters more than it looks.

Why I did not give ACP its own state machine

I have built enough agent tooling now to know what happens when a surface starts storing its own truth.

At first it feels convenient. The new client gets fast shortcuts. The code looks localized. You can add a field here, a cache there, maybe one special handler for the IDE case.

Then the drift begins.

One session store says the prompt happened.
Another store says it did not.
One adapter thinks cancellation means one thing.
Another adapter thinks it means something slightly different.
By the time you notice, the system is no longer one system.

So I kept ACP session state minimal. The adapter keeps just enough in memory to make the connection work: session id, conversation history, active turn, and a few counters for updates. No daemon-side ACP session database. No duplicate agent brain. No second source of truth.

That is boring. It is also easier to reason about.

The daemon already owns the real work. ACP should not compete with it.

The stream is the contract

The cleanest thing about this design is that ACP listens to the daemon's stream instead of inventing its own event language.

The mapping is plain:

  • assistant_text becomes agent_message_chunk
  • tool_start becomes tool_call
  • tool_result becomes tool_call_update
  • confirmation_required becomes an ACP permission round-trip
  • final becomes the session response

That one-to-one mapping matters because it keeps the adapter honest.

If the daemon says a tool started, ACP does not re-label it into something fancier. If the daemon ends with a final result, ACP does not hide it behind another layer of interpretation. The client sees the same work, only wrapped in ACP wire shapes.

I like that because it preserves debugging power. When something goes wrong, I can trace it through the same stream APX already uses. I do not need to ask whether ACP invented a separate failure mode.

Why history stays in the client connection

One subtle choice in the ACP code is that conversation history stays per connection, in memory.

That might sound like a compromise. For this surface, it is the right one.

The history exists so the ACP session can feed previousMessages on the next turn. That lets a client keep context without forcing the daemon to become a session database for every external protocol that touches it.

It also keeps the adapter narrow. The daemon still owns the real runtime. The ACP client owns its own conversation continuity. APX just bridges the turn.

That split is useful because ACP clients are not all the same. Some are IDEs. Some are editors. Some are automation surfaces. I do not want APX to guess their long-term memory model for them.

The real reason I wanted ACP at all

I did not add ACP because I wanted another badge on the README.

I added it because I want APX to reach more surfaces without changing its core shape.

The CLI is still the CLI.
The daemon is still the daemon.
The web admin is still a local window onto the same runtime.
ACP just lets other clients drive the same super-agent in a language they already speak.

That is the whole point of APC for me: one project context, many compatible tools. If APX is going to be the runtime layer for that idea, then protocol adapters should be cheap, local, and replaceable.

A thin ACP bridge gives me that.

A second brain would not.

What this changed in my thinking

This work changed how I judge new surfaces.

Now I ask a few blunt questions before I add anything:

  • Does this surface need its own state, or can it reuse the daemon?
  • Is this a translation layer, or am I sneaking in a fork?
  • Can I map events one-to-one, or am I inventing new semantics just because I can?

If the honest answer starts drifting toward "new brain," I stop and simplify.

That is not purity for its own sake. It is maintenance math.

Every extra brain creates another place where bugs can hide. Every thin adapter keeps the system closer to the contract I already trust.

The rule I kept

My rule for ACP ended up very close to the rule I keep for APC itself: preserve the contract, do not duplicate the contract.

APC says the project context should stay portable.
APX says the runtime should stay local.
ACP fits only if it stays a bridge between those ideas and another client, not a new center of gravity.

So I kept it thin.

That made the code easier to read.
It made cancellation simpler.
It made debugging less theatrical.
And it kept APX from growing the one thing I did not want to maintain: a second brain pretending to be just another adapter.

Top comments (0)