The CLIs Are Already Installed
The summarize step in the pipeline made an API call to Anthropic.
The same machine had claude in PATH. The same key. The same model. The CLI subscription that was already paid for. Sitting there.
The pipeline ignored it.
Every transcript I summarized went out over the network to an API, came back, and got written to disk. The local CLI binary that would have produced the same output for free against the same subscription was thirty centimeters away on the filesystem.
I changed that.
What NBP Is
NBP records and transcribes locally on macOS. The original post covers the why. After Whisper produces a transcript, the pipeline runs processing steps — summarize, extract action items, draft a note, whatever the user wired up.
Until this change, “processing” meant: pick a provider (OpenAI, Google, Anthropic, local LLM, Ollama), pass an API key, make a network call.
The local CLI agents — Claude Code, Codex, OpenCode — were not in the provider list. They were tools the user already had installed and authenticated. The pipeline was buying what was already on the shelf.
Splitting Transcription From Processing
The first cleanup was structural. Until this point, NBP had one “AI provider” setting. Whisper transcribed locally; everything else followed the same provider as the transcription source. That worked when the only path was OpenAI for both. It made no sense once Whisper was always local.
So: two settings. Transcription provider stays one thing — usually local Whisper. Processing provider becomes its own selector. Summarize and template extraction now follow the processing setting independent of how the transcript got there.
This split is what made adding CLI agents possible. It is also worth doing on its own. A user might want local Whisper for privacy and Claude API for processing, or local Whisper and a local CLI agent — both privacy-preserving in different ways. Coupling them was historical, not principled.
Three CLIs, One Connector
The connector lives in src-tauri/src/connectors/cli_agent.rs.
pub const SUPPORTED_CLIS: &[(&str, &str, &str)] = &[
("claude", "Claude Code", "npm install -g @anthropic-ai/claude-code"),
("codex", "Codex CLI", "npm install -g @openai/codex"),
("opencode", "OpenCode", "npm install -g opencode-ai"),
];
Three agents, one registry. Each entry has the binary name, the display name, and the install hint shown to the user when the binary is missing.
The pipeline step calls into one function:
pub async fn process_with_cli(
cli: &str,
prompt: &str,
transcript: &str,
model: Option<&str>,
timeout_secs: u64,
) -> Result<String, String>
Pick a CLI, hand it a prompt and a transcript, get back a string.
The branching that matters happens inside, because the three CLIs have nothing in common at the argv level.
The Argv Differs Per CLI
Claude Code takes the prompt with -p:
claude -p "<prompt>" --model claude-sonnet-4-6
Codex needs an exec subcommand:
codex exec "<prompt>" -m gpt-5
OpenCode uses run:
opencode run "<prompt>" -m gpt-5
The first version of this connector got Codex wrong. I had it as codex "<prompt>" — same shape as Claude. Codex with no subcommand drops into interactive mode, which on a piped stdin closes immediately and produces no output. The pipeline got an empty string and called it a successful run.
Fix in eee4065:
"codex" => {
let mut c = AsyncCommand::new("codex");
c.arg("exec").arg(&full_prompt);
if let Some(m) = model {
c.arg("-m").arg(m);
}
c
}
Three CLIs that look interchangeable. Three distinct invocation contracts. The connector pays the cost of remembering them so the pipeline does not have to.
Availability, Surfaced Honestly
A user without Codex installed should not see Codex as a working option in the dropdown.
pub fn check_cli_installed(cli: &str) -> bool {
Command::new("which")
.arg(cli)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[tauri::command]
pub fn check_cli_availability() -> Vec<CliInfo> {
SUPPORTED_CLIS.iter().map(|(id, name, hint)| {
CliInfo {
id: id.to_string(),
name: name.to_string(),
installed: check_cli_installed(id),
install_hint: hint.to_string(),
}
}).collect()
}
which, three times, on app start. The UI shows installed agents as selectable and missing agents as greyed out with a one-click hint to install.
✓ Claude Code (installed)
✓ Codex CLI (installed)
✗ OpenCode (npm install -g opencode-ai)
This is the one branch of the implementation a user actually sees. It is also the smallest. Twenty lines of Rust prevent every “why does this not work” support question from a user who has not noticed Codex was never on their machine.
Timeout, Then Kill, Then Reap
CLI agents can hang. They can stream output forever. They can wait for input on stdin that never comes.
The pipeline cannot afford that. Timeout is mandatory.
let mut child = cmd
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to spawn '{}': {}", cli, e))?;
let status = match tokio::time::timeout(
Duration::from_secs(timeout_secs),
child.wait(),
).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(format!("CLI process error: {}", e)),
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
return Err(format!("CLI agent '{}' timed out after {}s", cli, timeout_secs));
}
};
Three things happen on timeout. The child gets killed. The wait runs again to reap the corpse. The error gets the timeout reason embedded.
kill_on_drop(true) is the safety net. If the function unwinds for any reason — panic, early return, a different error path — the Child Drop impl kills the subprocess instead of leaving it.
Pre-kill_on_drop testing left orphaned claude processes hanging around for hours after a timeout. Activity Monitor lit up with stale agents. The fix is one line. The bug it prevents is one of those problems where the second run looks fine and the tenth run looks like the laptop fan is broken.
Prompt Templates
Pipeline configs declare prompt templates with placeholders. The CLI connector got prompt-template support in ffde3e4:
- type: cli_agent
cli: claude
prompt_template: |
Read this meeting transcript and produce action items.
Format: bullet points, owner in brackets.
Transcript:
{{transcript}}
timeout_secs: 120
{{transcript}} gets filled in at runtime. So does {{previous_step_output}} if a step depends on the one before it. The CLI connector validates that every placeholder used in the template has a binding before spawning the subprocess. If the template references {{summary}} and no upstream step produced one, the pipeline fails fast with a readable error instead of sending Claude a prompt with the literal string {{summary}} in it.
Validation is forty lines. Without it, Claude was happily summarizing prompts that included unreplaced placeholders, and I spent a week wondering why the outputs occasionally read like the model was talking to itself.
The Trade-Off
Cost: subprocess overhead. A CLI agent spin-up adds 200-500ms of process start before the first token is produced. An API call has its own latency, often higher, but the variance is different. CLI is more predictable but slower on small inputs.
Cost: harder to instrument. API calls have request IDs, structured logs, dashboards. CLI agents are stdout. Logging is whatever you scrape from stderr.
Cost: every CLI version bump can break the connector. Codex going from codex <prompt> to codex exec <prompt> happened once. It will happen again. The connector now has a smoke test on app start that runs each installed CLI with --version and captures the output, so version drift surfaces in logs before a user hits an empty pipeline output.
Benefit: the pipeline runs fully against tools the user already pays for. No new API key. No new bill line. The local-first promise of NBP extends to the processing step now, not just the recording and transcription.
Benefit: the CLI agents have access to local context — files, projects, anything in their working directory. A pipeline step can cd into a project root and ask Claude Code to draft a commit message based on the meeting transcript. The API path cannot do that. The CLI path can.
Takeaway
If a tool is already installed, that is one of the strongest signals about what to integrate next.
Three CLIs. One connector. Twenty lines of availability checks. One subprocess with a timeout that actually kills its own children.
The pipeline stopped paying for what was already on the shelf.
This concludes the NBP Follow-ups series.