DEV Community

CodeKing
CodeKing

Posted on

"I Stopped Choosing Between Claude Code and Codex. I Put Both in One Chat Window"

Every "Claude Code vs Codex" comparison eventually runs into the same boring truth:

I do not want to pick one forever.

Some tasks feel better in Claude Code. Some feel better in Codex. Some days one account is rate-limited, one model is cheaper, or one runtime is already holding the context I need.

The annoying part is not choosing the better agent.

The annoying part is switching surfaces every time I change my mind.

The workflow I wanted

I wanted one local chat window where I could do this:

Use Codex for this task.
Continue that same runtime.
Switch to Claude Code for the next one.
Ask the assistant to plan first.
Go back to direct runtime mode.
Enter fullscreen mode Exit fullscreen mode

That sounds like a UI problem, but it is really a control problem.

There are two different things happening:

  1. direct runtime work, where the next message should go straight to Claude Code or Codex
  2. assistant-mediated work, where a supervisor decides whether to answer, ask a question, or delegate to a runtime

If those two modes are not explicit, the chat window turns into a trap. A short follow-up like:

make it smaller
Enter fullscreen mode Exit fullscreen mode

can either mean:

  • continue the active Codex runtime
  • ask the product assistant
  • start a new Claude Code task
  • answer a pending approval

Guessing wrong here is exactly how coding agents become frustrating.

So I made direct runtime the default

In CliGate, the chat UI conversation now defaults to direct runtime mode.

That was a deliberate choice.

Most of the time, when I am using a coding agent, I do not want an assistant to intercept every message and "think about what I meant." I want the current runtime to continue until I explicitly ask for something else.

There is a test that pins this behavior:

test('ChatUiConversationStore defaults new chat-ui conversations to direct-runtime control mode', () => {
  const conversation = conversationStore.findOrCreateBySessionId('chat-ui-default-direct-runtime-1');

  assert.equal(conversation.metadata?.assistantCore?.mode, 'direct-runtime');
  assert.equal(conversation.metadata?.assistantCore?.controlMode, 'direct-runtime');
});
Enter fullscreen mode Exit fullscreen mode

That means a normal chat message does not automatically become "assistant work." It stays on the runtime path.

The two commands that made the UI usable

I ended up with a small mode switch instead of another complicated settings panel:

/cligate
/runtime
Enter fullscreen mode Exit fullscreen mode

The mode parser is intentionally tiny:

const cligateMatch = trimmed.match(/^\/cligate(?:\s+(.+))?$/is);
if (cligateMatch) {
  return {
    command: 'cligate',
    args: String(cligateMatch[1] || '').trim()
  };
}

if (/^\/runtime$/i.test(trimmed)) {
  return {
    command: 'runtime',
    args: ''
  };
}
Enter fullscreen mode Exit fullscreen mode

The behavior is:

  • /cligate enters assistant mode
  • /cligate <task> runs one assistant-mediated task
  • /runtime exits assistant mode and returns to direct runtime routing

That one escape hatch matters.

When I am done asking the assistant to plan or coordinate, I want the next message to go back to the active Claude Code or Codex session without ceremony.

The route now decides before touching the runtime

The chat route first gives the assistant mode service a chance to handle the message.

If assistant mode is not active and there is no /cligate command, it returns null, and the message goes down the normal runtime path:

const assistantResult = await this.assistantModeService.maybeHandleMessage({
  conversation,
  text,
  defaultRuntimeProvider,
  cwd,
  model,
  executionMode: assistantExecutionMode,
  onBackgroundResult
});

if (assistantResult) {
  return {
    ...assistantResult,
    previousSessionId: conversation.activeRuntimeSessionId || null,
    conversation: assistantResult.conversation || this.conversationStore.get(conversation.id)
  };
}
Enter fullscreen mode Exit fullscreen mode

Only after that does the service route directly to the runtime:

const result = await this.messageService.routeUserMessage({
  message: { text },
  conversation,
  defaultRuntimeProvider,
  cwd,
  model,
  metadata: {
    assistantMode: getAssistantControlMode(conversation),
    source: {
      kind: 'chat-ui',
      sessionId: String(sessionId || ''),
      conversationId: conversation.id
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

That separation is the whole point.

The assistant does not get to hijack direct runtime messages just because it exists.

Why this is better than a "smart" default

I tried to make the assistant helpful.

Then I realized "helpful" is dangerous in a coding workflow.

If a runtime is waiting for input, the least surprising thing is to send input to that runtime. If a task has a pending approval, the least surprising thing is to resolve that approval. If the user explicitly types /cligate, then the assistant can step in.

The result feels less magical, but much easier to trust.

For example:

fix the failing unit test
Enter fullscreen mode Exit fullscreen mode

can start a Codex runtime.

Then:

try the simpler patch
Enter fullscreen mode Exit fullscreen mode

continues that runtime.

Then:

/cligate compare this failure with the last run before continuing
Enter fullscreen mode Exit fullscreen mode

lets the assistant reason over the situation.

Then:

/runtime
Enter fullscreen mode Exit fullscreen mode

puts the conversation back on the direct runtime path.

That is the loop I wanted.

Background runs needed their own guardrail

The other bug showed up after I made assistant runs asynchronous in the chat UI.

If an assistant-mediated task starts a runtime and returns later, the UI needs to persist the background result. But it must not append stale output from an older assistant run after the user has already started a newer one.

So the route records the pending assistant run ID:

if (result?.type === 'assistant_run_accepted' && result?.assistantRun?.id) {
  chatUiConversationStore.patch(conversation.id, {
    metadata: {
      ...(conversation.metadata || {}),
      uiChatPendingAssistantRunId: String(result.assistantRun.id || '').trim()
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

And the background callback refuses stale results:

if (getPendingUiAssistantRunId(conversation) !== backgroundRunId) {
  return;
}
Enter fullscreen mode Exit fullscreen mode

That is not glamorous, but it prevents a very real UI bug:

  1. start an assistant task
  2. start another task before the first one finishes
  3. watch the old answer appear under the new task

No thanks.

The model override bug was another small footgun

There was one more detail that mattered for a mixed Claude Code / Codex chat surface.

The normal chat UI has a local model selector. Runtime routing has its own provider semantics. If I let the local chat model override leak into runtime routing, I could accidentally send something like gpt-5.4 into a Claude Code runtime path where that was not the user's intent.

So for local chat-ui runtime messages, the route deliberately ignores the UI chat model override:

const runtimeModelOverride = isExternalConversation ? String(model || '') : '';
Enter fullscreen mode Exit fullscreen mode

There is a test for that too:

assert.equal(captured[0].model, '');
assert.equal(captured[0].defaultRuntimeProvider, 'claude-code');
Enter fullscreen mode Exit fullscreen mode

That tiny rule saved the UI from pretending that "selected chat model" and "runtime provider" are the same concept.

They are not.

What the setup looks like

Start CliGate:

npx cligate@latest start
Enter fullscreen mode Exit fullscreen mode

Open the dashboard:

http://localhost:8081
Enter fullscreen mode Exit fullscreen mode

Then use the Chat page as the control surface:

  • choose Codex or Claude Code as the runtime provider
  • send a normal task to start direct runtime work
  • keep sending follow-ups to continue that runtime
  • use /cligate when you want assistant-mediated planning or delegation
  • use /runtime to return to the direct runtime path

That is the workflow I wanted from the beginning.

Not "which terminal agent wins?"

More like:

"Can I keep both available without rebuilding my workflow around either one?"

The lesson

The current wave of AI coding tools makes comparisons tempting.

Claude Code vs Codex. Codex vs Gemini CLI. Terminal agent vs IDE agent.

Those comparisons are useful, but they miss the day-to-day problem:

developers do not just choose tools. They move between them.

For me, the useful abstraction was not a smarter chatbot.

It was a chat control surface with explicit ownership:

  • direct runtime by default
  • assistant mode only when requested
  • sticky runtime continuation
  • stale background result protection
  • no accidental model override leaking into runtime work

That made Claude Code and Codex feel less like competing terminals and more like two workers behind the same local desk.

If you want to inspect the implementation, the project is here:

CliGate on GitHub

I'm curious how other people are handling this. Are you choosing one coding agent, or are you building a workflow that lets several of them coexist?

Top comments (0)