I like coding agents. But what I find almost more interesting than the agents themselves is the way we interact with them.
ChatGPT is a good example. I can start something in the browser, continue on my phone, and pick it up again in the desktop app. The interface changes, but the conversation and the capabilities behind it still feel like the same thing. That got me thinking about IBM Bob. Bob has a very capable shell and IDE, but I wanted to know what else I could build around it.
The newly released Agent Client Protocol support looked like the right place to start. ACP exposes much more than text going in and out of a process. It gives the client structured messages, tool calls, plans, modes, and, most importantly for me, permission requests that actually stop the agent until the client answers.
So I wanted to see how far I could push it.
And I probably would have spent quite a bit more time building the protocol layer myself if Charles Moulliard had not pointed me towards the experimental SmallRye ACP client. That gave me the missing Java piece and turned the experiment into a very natural Quarkus project.
The result is Bob Web. But I did not want to start with a big agent task or build a polished ChatGPT clone first. I wanted one small, predictable piece of Java and then watch what Bob actually needs from a client while working on it.
This is the repair I gave Bob::
package dev.mainthread.lab;
public final class RetryBudget {
private RetryBudget() {
}
public static boolean canRetry(int failedAttempts, int maxAttempts) {
return failedAttempts <= maxAttempts;
}
}maxAttempts includes the initial call, and failedAttempts says how many calls have already failed. The implementation has an off-by-one error and no input validation. The request to Bob is deliberately small:
Fix RetryBudget.java so it satisfies the README contract. Run ./verify. Change no other file.This is not an agent-coding challenge. Bob can handle < versus <=. I chose a boring problem because I want to see everything around the edit: which files Bob reads, the plan it publishes, the commands it wants to run, the change it proposes, and the points where it must wait for a human.
We will experience and control that work from a browser. Each thing Bob tries to do will force us to add one more part to a Quarkus client. By the end, the page will look familiar enough as a chat application, but chat turns out to be the easy part.
What You Need
I tested this path with Bob Shell 2.0.2, ACP protocol version 1, Java 21, and Quarkus 3.38.3. Set aside about 25 minutes and have these ready:
IBM Bob Shell on your
PATH, or its absolute pathA Bob API key or a working local Bob SSO session (Get a trial if you want)
Java 21 or newer
Apache Maven 3.9 or newer
Git
Review the Bob ACP license before automating its acceptance:
bob --show-license acpI know, license can be auto accepted --accept-license for non-interactive invokations, but reviewing the license is still your job.
Get the Code
Clone the application so we can spend our time on the protocol boundary rather than several hundred lines of CSS:
git clone https://github.com/myfear/the-main-thread.git
cd the-main-thread/bob-acp-webStart with a Known Failure
The lab directory includes a small contract check. Run it before we involve Bob:
cd lab
./verifyYou should see:
Exception in thread "main" java.lang.AssertionError: the budget is exhausted after three failures
at dev.mainthread.lab.RetryBudgetCheck.expect(RetryBudgetCheck.java:19)
at dev.mainthread.lab.RetryBudgetCheck.main(RetryBudgetCheck.java:11)Return to the application directory:
cd ..The failure seems predictable. The agent’s path to fixing it is not. Bob may read several files, publish a plan, or ask to run ./verify before it proposes a patch. Commands and edits arrive as permission requests, and Bob must stop until the client answers them.
A browser client therefore needs more than process output in a chat bubble. Parsing terminal text would make permissions, plans, tool state, and streaming depend on whatever Bob happens to print. That is awfully hard to parse and absolutely not what I want to implement.
Use the Protocol Bob Already Speaks
IBM Bob exposes those concepts through the Agent Client Protocol (ACP). IBM describes ACP mode as a split of responsibilities: the client owns the thread UI, permission dialogs, and diffs; Bob owns model access, tools, MCP servers, and shell execution. The two processes exchange JSON-RPC over standard input and output. IBM’s ACP documentation covers the process and its command-line options.
ACP gives us typed events and requests. An agent message arrives as an agent_message_chunk. A plan contains entries with status and priority. A tool call carries an ID, kind, title, input, output, and content. Most importantly for our repair, a permission prompt is a JSON-RPC request that remains open until the client returns one of Bob’s options.
The ACP overview compares this separation to the Language Server Protocol. A local agent runs as a child process and communicates over stdio. Remote transports are possible, but the local-process model gives this tutorial a clear first boundary:
One item in the browser’s left sidebar owns one Bob child process and one ACP session. The browser conversation ID is our local handle, while Bob’s session ID remains the protocol handle. Keeping them separate means a future persistence model does not depend on Bob’s identifier format.
Add the SmallRye ACP Client
The first SmallRye ACP Java client release is available from Maven Central. That keeps this project a normal Maven build: a fresh checkout does not need a second repository or a locally installed snapshot.
Add the Quarkus extensions and the ACP core dependency:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.ai</groupId>
<artifactId>acp-java-core</artifactId>
<version>0.1.0</version>
</dependency>quarkus-rest-jackson handles the JSON command API and server-sent events.
Snapshot API versions move. If these coordinates have changed by the time you read this, use the version built by the checked-out SmallRye repository and update the property in pom.xml. The tutorial code and tests were verified against 0.1.1-SNAPSHOT.
Keep the Permission Gate Visible
Export the Bob binary, key, and workspace root, then start Quarkus:
export BOB_PATH=/absolute/path/to/bob
export BOB_API_KEY='<your key>'
export BOB_WORKSPACE_ROOT="$PWD"
./mvnw quarkus:devOpen http://localhost:8080. At first, there is not much to see: an empty state and a New conversation button. Choose lab as the relative workspace. You will see how all of this is implemented when you follow along with the tutorial.
Before somebody turns this into a roadmap slide: Bob Web is a demo. A good-looking one, yes. But still a demo.
It is my personal experiment to see how far IBM Bob’s ACP support can be pushed from a Quarkus application. It is not an IBM product, product preview, product announcement, supported integration, commitment, roadmap item, or promise that anything resembling this will ever exist as a product.
The code is provided for experimentation and education. It uses experimental and/or evolving interfaces, may break without notice, and comes with no guarantees of support, compatibility, security, fitness for production, or continued availability. Do not expose it to untrusted users or networks without doing the security work discussed later in the article.
IBM, IBM Bob, and related names and marks belong to their respective owners. Other product and project names belong to their respective owners as well. Any opinions, architectural choices, questionable CSS decisions, and bugs in this demo are mine.
We will add meaning to the page as Bob tells us what the session can do.
The relevant configuration is small:
bob.binary=${BOB_PATH:bob}
bob.arguments=${BOB_ARGUMENTS:acp,--trust,--accept-license,--disable-mcp,--disable-subagents}
bob.workspace-root=${BOB_WORKSPACE_ROOT:${user.dir}}
bob.request-timeout=30s
bob.prompt-timeout=10m
bob.permission-timeout=2m
bob.max-conversations=4
quarkus.http.host=127.0.0.1
quarkus.http.limits.max-body-size=64KThe default arguments keep the first run contained. --disable-mcp avoids starting every configured MCP server once per conversation, and --disable-subagents keeps the process graph understandable. Remove either flag when you want that capability.
I intentionally left out --auto-approve. IBM warns that combining it with --trust removes both the workspace and tool-approval gates. Here, --trust is partly compensated by resolving every browser-supplied workspace beneath BOB_WORKSPACE_ROOT. Adding --auto-approve would remove the interaction we came to build: Bob could execute the repair without ever asking the ACP client.
Quarkus reads BOB_API_KEY as bob.api-key. The process builder passes it only to Bob’s child environment under BOBSHELL_API_KEY and a compatibility alias. The credential never appears in a DTO, event, or browser response.
Put a Bob Process Behind the Conversation
Before we can render a useful message, the new browser conversation needs a Bob process behind it. Creating one does four things:
Resolve and validate the workspace.
Start
bob acpwith separate stdout and stderr handling.Negotiate ACP version 1 with
initialize.Call
session/newand capture Bob’s session ID and mode state.
The factory is the only class that knows how Bob is launched:
@ApplicationScoped
class SmallRyeAcpConnectionFactory implements AcpConnectionFactory {
private static final Logger LOG = Logger.getLogger(SmallRyeAcpConnectionFactory.class);
private final BobConfig config;
SmallRyeAcpConnectionFactory(BobConfig config) {
this.config = config;
}
@Override
public AcpConnection open(Consumer<AcpEvent> eventConsumer,
Function<RequestPermissionRequest, CompletionStage<RequestPermissionResponse>> permissionHandler) {
AgentParameters.Builder parameters = AgentParameters.builder(config.binary());
for (String argument : config.arguments()) {
if (!argument.isBlank()) {
parameters.arg(argument.trim());
}
}
config.apiKey().filter(apiKey -> !apiKey.isBlank()).ifPresent(apiKey -> {
parameters.addEnvVar("BOB_API_KEY", apiKey);
parameters.addEnvVar("BOBSHELL_API_KEY", apiKey);
});
StdioAcpClientTransport transport = new StdioAcpClientTransport(parameters.build());
transport.setStdErrorHandler(line -> LOG.debugf("Bob stderr: %s", line));
return new SmallRyeAcpConnection(transport, config.requestTimeout(), config.promptTimeout(), eventConsumer,
permissionHandler);
}
}The ACP transport inherits only a conservative set of environment variables, so we add the key explicitly. We also keep stderr away from stdout because stdout carries JSON-RPC. One diagnostic line on that stream would corrupt the protocol.
ConversationService puts the object into its bounded map before it opens the transport. Callbacks can then find the conversation while the handshake is still in flight. If initialization fails, the service removes the reservation, closes the process, and completes the HTTP request with a 502.
Workspace validation happens before process creation:
private Path resolveWorkspace(String requestedWorkspace) {
String requested = requestedWorkspace == null || requestedWorkspace.isBlank() ? "." : requestedWorkspace.trim();
Path relative = Path.of(requested);
if (relative.isAbsolute()) {
throw new ApiException(400, "Workspace must be relative to the configured root");
}
Path resolved = workspaceRoot.resolve(relative).normalize();
if (!resolved.startsWith(workspaceRoot)) {
throw new ApiException(400, "Workspace must stay inside the configured root");
}
if (!Files.isDirectory(resolved)) {
throw new ApiException(400, "Workspace directory does not exist: " + requested);
}
return resolved;
}Absolute paths and ../ traversal are rejected. This is not a sandbox. Bob still runs processes with the current user’s permissions, but the browser API cannot select an arbitrary directory.
The response from session/new gives us the first reason to expand the UI: Bob reports Agent, Plan, and Ask modes with their descriptions. The selector is a projection of session state, not a hard-coded list in app.js.
Let Bob’s Updates Grow the UI
Now Bob can talk to us. The browser still should not care which JSON-RPC request produced a message, so the connection routes responses by numeric request ID and sends session/update notifications to a conversation projection. Incoming session/request_permission calls take a separate path that we will handle next. Unknown methods get a JSON-RPC -32601 response instead of disappearing into a log.
The projection maps ACP types to deliberately plain browser events:
switch (event.type()) {
case "agent_message_chunk" -> emitContent("agent_message_chunk", (ContentChunk) event.update());
case "agent_thought_chunk" -> emitContent("thought_chunk", (ContentChunk) event.update());
case "tool_call" -> emitTool("tool_call", (ToolCall) event.update());
case "tool_call_update" -> emitToolUpdate((ToolCallUpdate) event.update());
case "plan" -> emit("plan", Map.of("entries", ((Plan) event.update()).entries()));
case "available_commands_update" -> updateCommands((AvailableCommandsUpdate) event.update());
case "current_mode_update" -> modeChanged(((CurrentModeUpdate) event.update()).currentModeId());
case "session_info_update" -> updateSessionInfo((SessionInfoUpdate) event.update());
case "usage_update" -> emitUsage((UsageUpdate) event.update());
default -> emit("protocol_event", Map.of("name", event.type(), "payload", event.update()));
}Each case gives the page something concrete to render. agent_message_chunk grows Bob’s answer in place. available_commands_update populates the searchable Commands & Skills list. plan creates structured plan state. tool_call and tool_call_update give us an activity timeline keyed by toolCallId. Mode and session updates keep the header current. The UI grows from the protocol interaction instead of guessing what an agent might do.
Every event enters an in-memory buffer before it reaches subscribers. The SSE endpoint accepts an after sequence, replays newer events, and then registers the subscriber while holding the same lock. That closes the small but annoying gap where an update could arrive between REST hydration and live subscription:
@GET
@Path("/{id}/events")
@Produces(MediaType.SERVER_SENT_EVENTS)
public Multi<OutboundSseEvent> events(@PathParam("id") String id,
@QueryParam("after") @DefaultValue("0") long afterSequence, @Context Sse sse) {
return conversations.events(id, afterSequence).map(event -> sse.newEventBuilder()
.id(Long.toString(event.sequence()))
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(UiEvent.class, event)
.build());
}Quarkus REST supports reactive return types and SSE streams, so the endpoint returns a Mutiny Multi directly. Prompts, mode changes, permission decisions, and cancellation remain ordinary JSON requests. Most traffic flows from server to browser, which is why I chose SSE instead of adding a WebSocket lifecycle we did not need.
In app.js, the connection is equally plain:
state.eventSource = new EventSource(
`/api/conversations/${encodeURIComponent(state.current.id)}/events?after=${state.lastSequence}`
);
state.eventSource.onmessage = message => {
const event = JSON.parse(message.data);
applyEvent(event, true);
};All agent-controlled strings are assigned with textContent. ACP uses Markdown for human-readable content, but converting agent output directly to HTML would give tool output an unnecessary path into the DOM. Pretty Markdown can wait for a strict renderer and sanitizer.
Keep the ACP Request Pending
This is where Bob Web stops being a chat frontend.
Bob wants to run ./verify. At this point, nothing has been approved. Bob sent session/request_permission, and the JSON-RPC request is still unfinished. The backend must keep it that way while the browser shows the requested action and the exact options Bob supplied.
The conversation stores a CompletableFuture<RequestPermissionResponse> under the tool-call ID, emits the request to the browser, and returns the future to the transport:
public CompletionStage<RequestPermissionResponse> requestPermission(RequestPermissionRequest request) {
ToolCallUpdate toolCall = request.toolCall();
String toolCallId = toolCall.toolCallId();
List<PermissionOption> options = request.options() == null ? List.of() : List.copyOf(request.options());
CompletableFuture<RequestPermissionResponse> decision = new CompletableFuture<>();
PendingPermission pending = new PendingPermission(options, decision);
synchronized (pendingPermissions) {
pendingPermissions.put(toolCallId, pending);
}
emit("permission_requested", Map.of(
"toolCallId", toolCallId,
"title", valueOr(toolCall.title(), "Bob wants to use a tool"),
"kind", enumValue(toolCall.kind()),
"rawInput", valueOr(toolCall.rawInput(), Map.of()),
"content", valueOr(toolCall.content(), List.of()),
"options", options.stream().map(Conversation::permissionOption).toList()));
CompletableFuture.delayedExecutor(permissionTimeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
.execute(pending::completeOnTimeout);
return decision.whenComplete((response, failure) -> {
synchronized (pendingPermissions) {
pendingPermissions.remove(toolCallId);
}
if (failure == null) {
emit("permission_decided", Map.of(
"toolCallId", toolCallId,
"optionId", selectedOption(response),
"timedOut", pending.timedOut()));
}
});
}The round trip is the policy boundary. Bob sends the request. Quarkus keeps the future incomplete. The browser displays the action, input, content, and Bob-provided options. The human chooses an option. The POST endpoint validates that choice and completes the future. Only then does the ACP response go back to Bob and let the agent continue.
The endpoint accepts only an option ID Bob supplied for this request; the browser cannot manufacture a fifth choice. If nobody answers within two minutes, completeOnTimeout selects a reject option. If Bob supplied no reject option, the response is cancelled. Closing a browser tab therefore does not leave the agent waiting forever.
There is a subtle configuration trap here. With --auto-approve, Bob can perform the operation without sending the request to the client. A permission card cannot guard a request it never receives. “Always allow” in one permission response is also narrower than the process-wide flag, so keep those controls distinct.
Drive the Full Conversation
We finally have enough pieces to return to our three-line Java method.
Before the first prompt, the session header should show Bob 2.0.2. The mode selector contains Agent, Plan, and Ask, and Commands & Skills contains nine entries in my test session, including /create-skill, /create-mode, and /security. Those values are live protocol state: modes came from session/new, and commands arrived through available_commands_update. Another Bob installation may report a different list.
Start in Ask mode so Bob can inspect the failure without changing anything:
Inspect README.md and RetryBudget.java. Explain why the verification fails, but do not change any files.The browser shows Bob reading the relevant files and streams its explanation as message chunks. Tool rows change state in place because updates share a toolCallId; we do not get three unrelated rows for the start, progress, and completion of one read.
Now switch to Agent mode and send the repair:
Fix RetryBudget.java so it satisfies the README contract. Run ./verify. Change no other file.The exact order can vary because this is an agent, not a workflow engine. In my run, Bob:
Read the contract, implementation, and check.
Requested permission to execute the initially failing
./verify.Published a three-step plan.
Requested permission for a patch to
RetryBudget.java.Requested permission to run
./verifyagain.Returned
end_turnafter the command printedRetryBudget contract verified.
Read the command and patch in each permission card before choosing Allow once. Bob also requested permission for its internal todo update in my session, which is a useful warning against assuming every request is a Yes/No file dialog. Render the option list the agent supplied.
After approval, the repaired method validates both arguments and returns failedAttempts < maxAttempts. The final command prints:
RetryBudget contract verifiedThat line is intentionally uneventful. We reached it through a visible plan, streamed updates, tracked tool calls, and two decisions that kept Bob stopped until a human answered. The tiny bug did its job.
Turn the Integration Problems into Tests
The test suite replaces the real process factory with a mock ACP connection. This keeps the build deterministic while still exercising Quarkus REST serialization, status codes, CDI wiring, and static resource delivery.
Run it:
./mvnw testExpected result:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0Some tests follow directly from the architecture: a conversation initializes, accepts a prompt, changes modes, and closes its process; ../outside is rejected before process creation; the static client is served at /; permission completes with the exact selected option; and an unanswered request records timedOut: true after choosing Reject.
One test came from staring at Bob’s avatar beside an empty speech bubble. The ACP schema represents a content chunk as Object, so Jackson materialized it as a map instead of TextContent. The first implementation received the update and rendered no words. A focused test now proves that an untyped ACP content union containing text becomes an agent message. Protocol integration work tends to find the one union type you treated as concrete.
Reset the deliberately broken lab after the walkthrough:
git restore lab/src/main/java/dev/mainthread/lab/RetryBudget.javaWould You Trust This with Multiple Users?
The local client works. Now remove the assumptions that made it manageable: loopback networking, one operating-system user, in-memory conversations, a limit of four processes, no MCP servers, no subagents, local filesystem access, and no shared authentication or workspace authorization.
I would not expose the current application on a network. It binds to 127.0.0.1; stores conversations and up to 2,000 events per conversation in memory; and closes Bob processes, loses sidebar state, and clears the event history when Quarkus restarts. Bob also runs with the full permissions of the operating-system user.
A shared version needs authentication, authorization per workspace, CSRF protection, durable session and audit storage, per-user process quotas, secret management, and probably an operating-system sandbox. It also needs an explicit owner for Bob’s MCP configuration because IBM notes that every ACP session creates its own harness and may start the configured MCP servers.
Persistence is a useful next extension. The connection already implements ACP session list, load, and resume calls, but this tutorial treats active local processes as the source of truth. Before restoring a historical Bob session, I would persist the browser event projection and pending-decision audit record as well. A sidebar that remembers a title but loses the approval trail is only half durable.
Chat Was the Easy Part
At the beginning, I only wanted to see how far I could push Bob’s new ACP support from a browser.
The tiny RetryBudget fix gave us a good test case. Bob had to read files, publish state, ask for permission, propose an edit, run a command, and wait for us before continuing. Each of those steps forced another piece into the client.
And that is the part I find most interesting about ACP.
The chat window is almost incidental. The real work is everything around it: starting and stopping sessions, exposing modes and commands, tracking tool calls, streaming state, handling cancellation, and keeping permission requests open until a human makes a decision.
Quarkus gave us a convenient place to own that policy, and the SmallRye ACP client saved me from having to build the protocol layer from scratch. What started as “can I put Bob in a browser?” ended up being a much better look at what an agent client actually has to do.
This is still a local demo. I would not hand it to a team and call it a platform. But it is far enough along to make one thing very clear: once an agent exposes a proper protocol, the interesting interfaces are no longer limited to the shell or the IDE.
And yes, the browser version looks suspiciously like a product. Please refer to the disclaimer above before making slides. It is only a good looking demo.





