Recently I started testing multi-agent collaboration tools in my daily workflow, spending quite a bit of time with both Cumora and Raft. Between the two, I prefer how Raft handles agent definitions, interactions, and channel organization. Naturally, I wanted to plug in all the coding agents I rely on every day, including Codex, Claude Code, Antigravity, and Maka (an open-source coding agent under Apache Incubation, which I use with DeepSeek).
Popular options like Codex and Claude Code work out of the box, but Raft's official runtime list doesn't include Maka, and Antigravity is marked as deprecated. Looking at Raft's runtimeAdmissionService, any attempt to use the antigravity runtime name gets rejected right away with 400 Bad Request.
Could I just patch Raft's source code and add a custom adapter? In practice, that doesn't really work either. Although Raft publishes its code at botiverse/raft-source under FSL-1.1-ALv2 (a Source Available license) which allows personal modifications, the repository is currently a read-only release mirror that doesn't accept external PRs. Maintaining a private fork would mean manually syncing squashed release snapshots on every update, and connecting to the official service would still run into server-side runtime allowlists and client code-signing checks.
While reading through Cumora and Raft's source code to see how they integrate external CLIs, I broke down the three ways to invoke agy (covered in my previous post) and came across ACP (Agent Client Protocol). Then, when I checked Raft's Grok driver (packages/daemon/src/drivers/grok.ts), I realized it also speaks ACP over standard stdio under the hood.
That opened up a clean path that doesn't touch Raft's code at all: place a thin grok proxy script early on PATH, piggyback on Raft's existing Grok adapter, and route requests over ACP to Antigravity, Maka, or any other ACP-compatible agent.
Before going down the proxy route, I cloned raft-source locally to run a few experiments.
Raft's old antigravity.deprecated.ts driver spawned a fresh agy chat -m child process on every turn, turning each message into a cold-start single-turn call. Switching to a persistent stream-json pipe kept the process warm across turns and made group chat interactions much faster. Going one step further, copying grok.ts and pointing it directly at agy_acp_server.par worked even better; as analyzed in the previous post, ACP has clear advantages over stream-json.
Since modifying the client app directly wasn't a sustainable option, I looked closely at what packages/daemon/src/drivers/grok.ts actually does:
lookupCommandOnPath("grok") to find the executable on PATH;grok --version and grok agent stdio --help;grok agent --no-leader --always-approve stdio and exchanges ACP messages over stdio.There is a neat macOS detail here: GUI apps don't inherit your terminal PATH by default, so Raft's packages/computer/src/shellEnvCapture.ts launches your login shell (-ilc) with __print-env at startup to capture your shell environment.
That means if we symlink our proxy script to ~/.local/bin/grok, Raft's Desktop.app picks it up automatically. To pass the initial probes, the script just prints a version string for --version and help text for agent stdio --help. The real work happens after entering stdio mode to align protocol details. Here are two basic handshake differences:
GrokDriver calls session/new right after initialize, whereas agy's ACP server requires an authenticate({ methodId: "oauth-personal" }) call first or it rejects session creation as unauthenticated. Our proxy simply inserts that authentication call before forwarding session/new._meta.systemPromptOverride on session/new. That field isn't part of standard ACP, so both agy and Maka ignore it. The proxy extracts _meta.systemPromptOverride, holds onto it, and prepends it (along with environment context like the current working directory, OS, and timezone) to the first session/prompt of that session. Maka's Runtime Host already injects environment context on its own, so the extra environment block is only added for agy.Once agy was running smoothly, I moved on to wiring up Maka (maka acp). That immediately raised a routing question: if multiple agents share the same grok entry point, how does the proxy know which backend a session should run?
Raft lets you configure custom environment variables for each agent, so I added a strict rule in the proxy: every agent must explicitly set GROK_SHIM_PROFILE=agy or GROK_SHIM_PROFILE=maka. If that variable is missing, the proxy throws an error on session/new and session/load rather than guessing a default.

Right after that came a second problem: when Raft probes which models a runtime supports (detectGrokModelsFromAcp in grok.ts), it spawns a machine-level grok process and sends initialize before any specific agent is started, so no agent environment variables are present. Since we can't rely on environment variables during model detection, I encoded the routing information directly into the model IDs returned by the proxy:
GROK_SHIM_PROFILE is unset), the proxy merges the model catalogs from all backends and prefixes every modelId with agy_ or maka_, such as agy_gemini-4-argon-medium or maka_deepseek-flash.GROK_SHIM_PROFILE (if a maka agent accidentally selects an agy_ model, it fails immediately with a clear error), strips the prefix, and forwards the real model ID to the backend.
Finally, in case I ever install the official grok CLI on this machine in the future, the proxy also supports transparent passthrough to the real binary (though I don't have a Grok subscription, so I haven't tested that path against a live account).
session/load: Aligning Old and New ACP VersionsWith prefix routing in place, the next step to get sessions running was handling model switching and Reasoning Effort.
Raft's GrokDriver switches models by calling session/set_model from an early ACP draft and passing the reasoning effort in _meta.reasoningEffort. agy implements that early method (for personal accounts, it bakes the effort level into three separate model IDs: gemini-4-argon-low, gemini-4-argon-medium, and gemini-4-argon-high).
Maka, on the other hand, follows the strict ACP v1 specification and doesn't support session/set_model (returning -32601). Under ACP v1's Session Config Options design, session settings like the model and reasoning effort live in a unified configOptions array and are updated via session/set_config_option.
Maka's backend daemon already had the ability to switch models, and when contributing to an open-source project like Maka, we obviously shouldn't hardcode non-standard or vendor-private methods like session/set_model or _x.ai/* into its ACP server just to please one client. So I opened a PR (apache/maka#6189) to expose the standard model option in configOptions (listing only the models currently enabled by the user, and relaxing the static option allowlist at the adapter entry point).
This also illustrates the three-layer division of responsibilities we use across the whole integration (including Steer in the next section):
packages/cli/src/acp/) to expose standard capabilities that are already defined in the ACP spec and supported by its runtime, making the changes clean upstream contributions;GrokDriver to send session/set_model and _x.ai/* requests as usual;grok proxy handles all protocol translation, including model probing, prefix stripping, effort splitting, and error message rewriting.Neither side has to pollute its codebase for the other, and all translation logic stays in the proxy:
initialize (before any session exists), whereas Maka returns configOptions on session/new. During initialization, the proxy opens a brief probe session against Maka, reads the model and thinking-level lists, and immediately calls session/close so no orphan sessions are left behind.session/set_model, the proxy splits it into one or two session/set_config_option calls to update Maka's model and thinking_level.Session resumption (session/load) had a small catch as well: per the ACP v1 spec, a successful session/load returns configuration options without repeating the sessionId field. Our proxy initially shared validation logic with session/new and required sessionId in the response, causing history resumption to fail. Passing the requested sessionId through and rewriting unknown-session errors into the "session not found" string that GrokDriver looks for made session resume and fallback creation work smoothly.
In a multi-agent group chat like Raft, a very common action is sending a follow-up message while an agent is still working on a turn, whether to add context, adjust requirements, or tell it to stop.
Supporting this experience looked very different across the two agents:
agy is a closed-source binary (agy_acp_server.par), and testing showed it doesn't support mid-turn steering at the bottom layer. Its ACP service defines no steering methods; if you force a second concurrent session/prompt before the current turn finishes, the underlying connection throws a concurrency exception, and the outer handler catches it and streams the error text into the chat window as a normal assistant reply. Worse yet, its Python base class acp.interfaces.Agent defines the extension entry point ext_method with a default ... (Ellipsis) body, which causes any _-prefixed extension request to return result: null (pretending to succeed while silently dropping the message). For agy, the proxy has to use a queue-and-hold workaround: buffer incoming busy messages in memory, hold back the completion response when the current turn ends, and send the queued messages as sequential session/prompt calls.
Maka, however, is open source. Checking its codebase showed that its background daemon (Runtime Host) already had scheduling logic for inserting messages mid-turn; the ACP adapter under packages/cli/src/acp/ simply only ever called turn.start and hadn't exposed mid-turn steering yet. With access to the source, we could wire up true Steer support in Maka directly (apache/maka#6192).
Before writing code, we needed to sort out how the protocol works on each side.
When a session is busy, Raft's grok.ts doesn't send session/prompt; instead, it sends a Grok-private extension request _x.ai/interject, falling back to session/steer only if the backend returns -32601 Method not found.
Following the same approach we took for model and thinking_level, instead of teaching Maka to handle _x.ai/interject, we followed the ACP design proposals for Mid-turn Injection (RFD #1261, Schema #2043) and exposed a pair of standard methods on Maka's ACP adapter:
session/inject: accepts sessionId, content, and mode: "steer" | "queue". steer hands the new message to the model at the next step boundary inside the current turn (such as right after a tool call finishes), while queue waits until the current turn ends. It returns a server-generated messageId.session/revoke_inject: allows the client to withdraw a queued message by its messageId before the model consumes it.On the grok proxy side, we simply intercept _x.ai/interject from Raft and translate it into session/inject { mode: "steer" } for Maka.
Maka's architecture splits into a lightweight maka acp CLI process and a persistent Runtime Host background daemon. Mapping stateless protocol requests onto a stateful background execution stream surfaced three practical engineering issues:
steer request just as the server finishes the last step of the turn. When Maka's background daemon receives a mid-turn submission while no turn is running, its default behavior is to start a brand-new turn automatically. Because that turn wasn't started through a normal session/prompt call, the ACP adapter has no output listener attached to it, so the client never sees the reply. To prevent this, the adapter verifies that the session actually has an active turn before forwarding, and rejects the request with a precondition error if the session is already idle.hostEpoch) and requires write requests to carry it so stale commands from before a restart are rejected. Initially, the ACP adapter copied hostEpoch into a static property when creating the connection wrapper. If the daemon restarted mid-session and auto-reconnected, subsequent session/inject calls kept sending the old identifier and failed. Switching the property on the connection wrapper to a dynamic getter that reads the active connection's current identifier fixed the issue.After all offline proxy tests passed, I switched back to the release build of Raft v1.21.24 to test in the UI. When I clicked scan in Settings -> Runtimes, every local agent on my machine (including Claude Code, Codex, and Grok Build) showed up as not installed.
Checking ~/.slock/computer/logs/service.log and runtimeDetectionRound.ts in raft-source revealed why: when the daemon spawns a child process to probe runtimes, it assumes process.argv[1] is the entry script path. In the packaged Electron app, however, argv[1] is actually the launch mode flag "__service", causing the child process under ELECTRON_RUN_AS_NODE=1 to try loading "__service" as a file from the root directory and exit immediately with Cannot find module '/__service'.
I wrote up a reproduction report and sent it to the Raft developers. Until an official release fixes that argv[1] check, starting the compiled raft-computer.js daemon directly with node works around the problem.
The entire grok proxy came out to around 1,400 lines of plain Node.js code with zero third-party dependencies. For daily use, all it takes is:
maka runtime-host status);GROK_SHIM_PROFILE=agy and GROK_SHIM_PROFILE=maka, and pull them into the same channel to collaborate.
Even if the protocol evolves later (for instance, if grok adopts standard ACP mid-turn steering), the changes needed on our side will be tiny, making this a low-maintenance way to stay compatible.
Looking back on this whole exercise, two thoughts stand out:
raft-source doesn't accept issues or open discussions yet, and I hope once the Raft team figures out their community collaboration model, they'll open up more room for discussion and contributions.