Back to Insights
AI & Machine Learning•I Built a Local Agent Menu Bar: Routing Claude, Codex, and Open-Source Models Without Lock-In•tutorial•October 31, 2026•18 min read

Building a Local Agent Menu Bar: Routing Claude, Codex, and Open-Source Models Without Lock-In

Learn how to build a cross-platform menu bar app that routes prompts to Claude Code, Codex, and local Ollama models through a unified provider interface — no vendor lock-in.

T
Tamiz UddinFull-Stack Engineer

Most AI coding agents are great at what they do, but they’re also silos. Claude Code lives in its own terminal, Codex in another, and your local Ollama models are a separate world. If you want to switch between them without losing context, you end up copy-pasting prompts and losing the thread. I built a small menu bar app that routes a single prompt to any agent through a unified interface — and you can build the same thing in about an hour. Here’s the full walkthrough.

1. Architecture Overview

The app is an Electron menu bar utility with three layers:

  • Main process — owns the Tray, the frameless window, and spawns/kills child processes for each agent.
  • Preload script — exposes a safe, typed IPC bridge (window.agentAPI) to the renderer.
  • Renderer (React) — the prompt input, provider selector, streaming output pane, and cancellation button.

The key design decision is a provider interface. Every agent (Claude, Codex, Ollama) implements the same contract:

ts
interface AgentProvider {
  id: string;                  // 'claude' | 'codex' | 'ollama'
  label: string;
  buildCommand(prompt: string, opts: RunOptions): string[];
  parseOutput(chunk: string): string;  // normalize streamed text
}

This keeps the UI completely agnostic. Adding a new agent means writing one provider file — no changes to the renderer or the tray logic.

2. Prerequisites

  • Node.js 20+ and npm
  • Electron 28+ (we’ll use electron and electron-builder)
  • Claude Code CLI installed and authenticated (claude on PATH)
  • Codex CLI installed and authenticated (codex on PATH)
  • Ollama installed with a model pulled (e.g. ollama pull llama3.2)
  • Basic familiarity with Electron IPC and React hooks

3. Step 1: Scaffold the Electron Project

Create a new directory and initialize a minimal Electron + Vite + React setup:

bash
mkdir agent-menu-bar && cd agent-menu-bar
npm init -y
npm install --save-dev electron electron-builder vite @vitejs/plugin-react typescript
npm install react react-dom
npx tsc --init

Set up the folder structure:

bash
agent-menu-bar/
├── src/
│   ├── main/
│   │   ├── index.ts
│   │   ├── tray.ts
│   │   ├── providers/
│   │   │   ├── index.ts
│   │   │   ├── claude.ts
│   │   │   ├── codex.ts
│   │   │   └── ollama.ts
│   │   └── runner.ts
│   ├── preload/
│   │   └── index.ts
│   └── renderer/
│       ├── index.html
│       ├── main.tsx
│       └── App.tsx
├── package.json
└── tsconfig.json

4. Step 2: Build the Provider Abstraction

Create the shared types and the provider registry. First, src/main/providers/index.ts:

ts
export interface RunOptions {
  cwd: string;
  model?: string;      // for Ollama
  systemPrompt?: string;
  maxTokens?: number;
}

export interface AgentProvider {
  id: string;
  label: string;
  buildCommand(prompt: string, opts: RunOptions): string[];
  parseOutput(chunk: string): string;
}

Now implement claude.ts. Claude Code accepts a prompt via -p and streams to stdout. We use --output-format stream-json for structured streaming, then parse each JSON line:

ts
import { AgentProvider, RunOptions } from './index';

export const claudeProvider: AgentProvider = {
  id: 'claude',
  label: 'Claude Code',
  buildCommand(prompt: string, opts: RunOptions): string[] {
    return [
      'claude',
      '-p', prompt,
      '--output-format', 'stream-json',
      '--verbose'
    ];
  },
  parseOutput(chunk: string): string {
    // Claude stream-json emits lines like: {"type":"assistant","message":{"content":"..."}}
    const lines = chunk.split('\n').filter(Boolean);
    return lines.map(line => {
      try {
        const obj = JSON.parse(line);
        if (obj.type === 'assistant' && obj.message?.content) {
          return obj.message.content;
        }
        return '';
      } catch {
        return '';
      }
    }).join('');
  }
};

Next, codex.ts. Codex has a non-interactive mode with exec:

ts
import { AgentProvider, RunOptions } from './index';

export const codexProvider: AgentProvider = {
  id: 'codex',
  label: 'Codex CLI',
  buildCommand(prompt: string, opts: RunOptions): string[] {
    return [
      'codex',
      'exec',
      '--full-auto',
      prompt
    ];
  },
  parseOutput(chunk: string): string {
    // Codex outputs plain text to stdout
    return chunk;
  }
};

Finally, ollama.ts. Ollama isn’t a CLI agent in the same sense — it’s a local HTTP server. But we can wrap it as a provider by calling its REST API via curl or by using the ollama run command. I’ll use ollama run with --format json for clean streaming:

ts
import { AgentProvider, RunOptions } from './index';

export const ollamaProvider: AgentProvider = {
  id: 'ollama',
  label: 'Ollama (Local)',
  buildCommand(prompt: string, opts: RunOptions): string[] {
    const model = opts.model ?? 'llama3.2';
    return [
      'ollama',
      'run',
      model,
      prompt,
      '--format', 'json'
    ];
  },
  parseOutput(chunk: string): string {
    // Ollama JSON streaming emits lines like: {"response":"...","done":false}
    const lines = chunk.split('\n').filter(Boolean);
    return lines.map(line => {
      try {
        const obj = JSON.parse(line);
        return obj.response ?? '';
      } catch {
        return '';
      }
    }).join('');
  }
};

Now create a registry to look up providers by ID:

ts
import { claudeProvider } from './claude';
import { codexProvider } from './codex';
import { ollamaProvider } from './ollama';
import { AgentProvider } from './index';

export const providers: Record<string, AgentProvider> = {
  claude: claudeProvider,
  codex: codexProvider,
  ollama: ollamaProvider
};

export function getProvider(id: string): AgentProvider {
  const p = providers[id];
  if (!p) throw new Error(`Unknown provider: ${id}`);
  return p;
}

5. Step 3: Create the Menu Bar Tray

In src/main/tray.ts, create a Tray with an icon and a context menu. The tray is the primary interaction point — clicking it opens the agent window, and the menu offers quick actions:

ts
import { Tray, Menu, nativeImage, app } from 'electron';
import { BrowserWindow } from 'electron';

export function createTray(win: BrowserWindow): Tray {
  // Use a 16x16 or 32x32 PNG. For production, bundle a proper template image.
  const icon = nativeImage.createFromPath(
    app.isPackaged
      ? `${process.resourcesPath}/trayTemplate.png`
      : 'assets/trayTemplate.png'
  );
  icon.setTemplateImage(true); // macOS: adapts to light/dark menu bar

  const tray = new Tray(icon);
  tray.setToolTip('Agent Router');

  const contextMenu = Menu.buildFromTemplate([
    { label: 'Open Agent Router', click: () => win.show() },
    { type: 'separator' },
    {
      label: 'Quit',
      click: () => app.quit()
    }
  ]);

  tray.setContextMenu(contextMenu);

  // Left-click toggles the window (Windows/Linux)
  tray.on('click', () => {
    if (win.isVisible()) {
      win.hide();
    } else {
      win.show();
      win.focus();
    }
  });

  return tray;
}

6. Step 4: Wire Up IPC and Process Spawning

Now the heart of the app — src/main/runner.ts. This module spawns the provider’s command, streams stdout/stderr back to the renderer, and supports cancellation:

ts
import { spawn, ChildProcess } from 'child_process';
import { ipcMain, BrowserWindow } from 'electron';
import { getProvider } from './providers';
import { RunOptions } from './providers';

let activeProcess: ChildProcess | null = null;

export function registerRunnerIPC(win: BrowserWindow) {
  ipcMain.handle('agent:run', async (_event, { providerId, prompt, options }) => {
    if (activeProcess) {
      throw new Error('An agent is already running. Cancel it first.');
    }

    const provider = getProvider(providerId);
    const args = provider.buildCommand(prompt, options as RunOptions);

    // Spawn the process with stdio pipes for streaming
    activeProcess = spawn(args[0], args.slice(1), {
      cwd: options?.cwd ?? process.cwd(),
      env: { ...process.env, FORCE_COLOR: '0' }
    });

    activeProcess.stdout.on('data', (data: Buffer) => {
      const text = provider.parseOutput(data.toString());
      if (text) {
        win.webContents.send('agent:output', text);
      }
    });

    activeProcess.stderr.on('data', (data: Buffer) => {
      const text = data.toString();
      win.webContents.send('agent:error', text);
    });

    activeProcess.on('close', (code) => {
      win.webContents.send('agent:done', code);
      activeProcess = null;
    });

    return { ok: true };
  });

  ipcMain.handle('agent:cancel', () => {
    if (activeProcess) {
      activeProcess.kill('SIGTERM');
      activeProcess = null;
      return { ok: true };
    }
    return { ok: false };
  });
}

Now the preload script src/preload/index.ts — expose a typed API to the renderer:

ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('agentAPI', {
  run: (payload: { providerId: string; prompt: string; options?: any }) =>
    ipcRenderer.invoke('agent:run', payload),
  cancel: () => ipcRenderer.invoke('agent:cancel'),
  onOutput: (callback: (text: string) => void) => {
    const listener = (_: any, text: string) => callback(text);
    ipcRenderer.on('agent:output', listener);
    return () => ipcRenderer.removeListener('agent:output', listener);
  },
  onError: (callback: (text: string) => void) => {
    const listener = (_: any, text: string) => callback(text);
    ipcRenderer.on('agent:error', listener);
    return () => ipcRenderer.removeListener('agent:error', listener);
  },
  onDone: (callback: (code: number) => void) => {
    const listener = (_: any, code: number) => callback(code);
    ipcRenderer.on('agent:done', listener);
    return () => ipcRenderer.removeListener('agent:done', listener);
  }
});

7. Step 5: Build the React UI

In src/renderer/App.tsx, create the main component. It manages provider selection, prompt input, streaming state, and cancellation:

tsx
import { useEffect, useRef, useState } from 'react';

const PROVIDERS = [
  { id: 'claude', label: 'Claude Code' },
  { id: 'codex', label: 'Codex CLI' },
  { id: 'ollama', label: 'Ollama (Local)' }
];

export default function App() {
  const [providerId, setProviderId] = useState('claude');
  const [prompt, setPrompt] = useState('');
  const [output, setOutput] = useState('');
  const [running, setRunning] = useState(false);
  const outputRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const offOutput = window.agentAPI.onOutput((text) => {
      setOutput(prev => prev + text);
    });
    const offError = window.agentAPI.onError((text) => {
      setOutput(prev => prev + `\n[ERROR] ${text}`);
    });
    const offDone = window.agentAPI.onDone((code) => {
      setRunning(false);
      setOutput(prev => prev + `\n\n--- Exited with code ${code} ---`);
    });
    return () => { offOutput(); offError(); offDone(); };
  }, []);

  useEffect(() => {
    if (outputRef.current) {
      outputRef.current.scrollTop = outputRef.current.scrollHeight;
    }
  }, [output]);

  const handleRun = async () => {
    if (!prompt.trim() || running) return;
    setRunning(true);
    setOutput('');
    await window.agentAPI.run({
      providerId,
      prompt,
      options: { cwd: process.cwd() }
    });
  };

  const handleCancel = async () => {
    await window.agentAPI.cancel();
    setRunning(false);
  };

  return (
    <div className="app">
      <header>
        <select value={providerId} onChange={e => setProviderId(e.target.value)}>
          {PROVIDERS.map(p => (
            <option key={p.id} value={p.id}>{p.label}</option>
          ))}
        </select>
        <button onClick={running ? handleCancel : handleRun} disabled={!prompt.trim()}>
          {running ? 'Cancel' : 'Run'}
        </button>
      </header>
      <textarea
        value={prompt}
        onChange={e => setPrompt(e.target.value)}
        placeholder="Type your prompt..."
        rows={4}
      />
      <div className="output" ref={outputRef}>
        {output || 'Output will appear here...'}
      </div>
    </div>
  );
}

8. Step 6: Streaming Output and Cancellation

Streaming works through the IPC events we set up. The renderer accumulates chunks into state, and the main process forwards every parsed chunk. Cancellation kills the child process with SIGTERM, which is the cleanest way to stop a CLI agent mid-stream. Note that some agents (especially Claude Code) may need SIGINT instead — you can make the kill signal configurable in the provider interface if you need that granularity.

One subtle bug to avoid: if the user switches providers mid-run, the old process keeps streaming. The activeProcess guard in runner.ts prevents concurrent runs, but you should also clear the output when the provider changes. Add this effect:

tsx
useEffect(() => {
  if (!running) {
    setOutput('');
  }
}, [providerId]);

9. Step 7: Global Hotkey and Packaging

Register a global shortcut so you can summon the window from anywhere. In src/main/index.ts:

ts
import { globalShortcut } from 'electron';

app.whenReady().then(() => {
  // ... create window and tray
  globalShortcut.register('CommandOrControl+Shift+Space', () => {
    if (win.isVisible()) {
      win.hide();
    } else {
      win.show();
      win.focus();
    }
  });
});

For packaging, add a build script to package.json using electron-builder. Here’s a minimal config:

json
{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "dist": "npm run build && electron-builder"
  },
  "build": {
    "appId": "com.example.agentmenubar",
    "productName": "Agent Menu Bar",
    "files": ["dist/**/*", "src/main/**/*", "src/preload/**/*"],
    "mac": { "category": "public.app-category.developer-tools" },
    "win": { "target": "nsis" },
    "linux": { "target": "AppImage" }
  }
}

10. Production Best Practices

  • Validate provider availability: Before running, check if the CLI binary exists (which claude, which codex, which ollama) and show a friendly error in the UI.
  • Persist settings: Use electron-store to remember the last provider and working directory.
  • Handle long-running processes: Add a timeout (e.g. 10 minutes) and kill the process if it exceeds the limit.
  • Secure IPC: Never pass arbitrary shell commands from the renderer. The provider interface whitelists commands; the renderer only sends providerId and prompt.
  • Cross-platform paths: Use path.join() instead of string concatenation for the tray icon path.
  • Logging: Write agent stderr to a log file (app.getPath('userData') + '/logs') for debugging.

11. Frequently Asked Questions

Q: Can I add a new agent without touching the UI?

Yes. Implement the AgentProvider interface in a new file, register it in providers/index.ts, and add it to the PROVIDERS array in App.tsx. That’s it — the UI and IPC logic remain unchanged.

Q: Why not just use a terminal multiplexer like tmux?

A menu bar app gives you a persistent, always-available surface with a native feel — hotkey access, tray icon, and a clean UI. tmux is great for terminal workflows but doesn’t integrate with the OS menu bar or global shortcuts without extra tooling.

Q: How do I handle agents that require interactive TTY?

Some agents (like older versions of Claude Code) need a pseudo-terminal. You can spawn them with node-pty instead of child_process.spawn. The provider interface stays the same; only the runner changes. This is a good extension point if you hit TTY-related issues.


This pattern — a provider abstraction, a thin IPC layer, and a tray-based UI — gives you a genuinely useful tool that treats every AI agent as a pluggable backend. No more copy-pasting between terminals. You can find the full source code for this project on Tamiz's Insights or explore more developer tooling guides at tamiz.pro.