← Blog

How to Build a Claude CLI Tool for Your Terminal

2026-10-29 · 5 min read · SubToAPI Team

If you want to build a Claude CLI tool, the fastest path is: pick a language runtime with good process/stdio support (Node.js is the most common choice), wrap Claude's chat completion endpoint in a small HTTP client, parse arguments with a lightweight CLI framework, and stream the response token-by-token to stdout so the tool feels responsive. That's the whole architecture — everything else is polish.

This guide walks through that architecture step by step: setting up the project, handling authentication, streaming responses, managing conversation state across commands, and packaging the tool so other people can install it with a single command. By the end you'll have a working claude command you can run from any terminal.

Project setup

Start with a standard Node.js CLI skeleton. You don't need a framework to get going — a single file with a shebang line works fine for prototyping.

mkdir claude-cli && cd claude-cli
npm init -y
npm install commander node-fetch dotenv

commander handles argument parsing, node-fetch makes HTTP requests, and dotenv loads your API key from a local .env file during development.

Create a bin/claude.js entry point:

#!/usr/bin/env node
require('dotenv').config();
const { program } = require('commander');

program
  .name('claude')
  .description('Talk to Claude from your terminal')
  .argument('<prompt>', 'the message to send')
  .option('-m, --model <model>', 'model name', 'claude-sonnet')
  .action(async (prompt, opts) => {
    await runPrompt(prompt, opts.model);
  });

program.parse();

Reference it in package.json so npm link turns it into a global command:

{
  "bin": { "claude": "./bin/claude.js" }
}

Run npm link locally and claude "explain async/await" should resolve to your script — even if runPrompt isn't implemented yet.

Authentication and request handling

A CLI tool needs an API key the user controls, stored outside the repo. The standard pattern is an environment variable plus an optional config file:

function getApiKey() {
  if (process.env.SUBTOAPI_KEY) return process.env.SUBTOAPI_KEY;
  const configPath = path.join(os.homedir(), '.claude-cli', 'config.json');
  if (fs.existsSync(configPath)) {
    return JSON.parse(fs.readFileSync(configPath, 'utf8')).apiKey;
  }
  throw new Error('No API key found. Run `claude config set-key <key>`');
}

This matters more than it looks: distributing a CLI tool means you can't assume a .env file exists on the user's machine. A home-directory config file (~/.claude-cli/config.json) is the convention used by most CLI tools (git, npm, aws-cli all do some variant of this).

If you're building against Claude directly, you'll need to handle Anthropic's raw messages API, request signing, and model-specific headers yourself. If you'd rather skip that layer, SubToAPI gives you a single HTTPS API with a sub_live_... key that proxies your Claude access — so your CLI's auth logic is just a bearer token, with no separate key rotation or header management to maintain. See the quickstart for the exact request format.

Streaming output to the terminal

A CLI that prints the full response only after it's done feels slow. Streaming makes it feel alive — tokens appear as they're generated, the same way Claude's own interface works.

async function runPrompt(prompt, model) {
  const res = await fetch('https://api.subtoapi.app/v1/messages', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${getApiKey()}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model,
      stream: true,
      messages: [{ role: 'user', content: prompt }]
    })
  });

  for await (const chunk of res.body) {
    const lines = chunk.toString().split('\n').filter(Boolean);
    for (const line of lines) {
      if (!line.startsWith('data:')) continue;
      const data = JSON.parse(line.replace('data: ', ''));
      if (data.delta?.text) process.stdout.write(data.delta.text);
    }
  }
  process.stdout.write('\n');
}

This parses server-sent events and writes text deltas directly to stdout as they arrive. Full details on the event format are in the streaming docs.

Keeping conversation state between commands

A one-shot prompt tool is useful, but most people want a CLI that remembers context across invocations, like a persistent session. The simplest implementation stores the message history in a local JSON file:

const historyPath = path.join(os.homedir(), '.claude-cli', 'history.json');

function loadHistory() {
  if (!fs.existsSync(historyPath)) return [];
  return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
}

function saveHistory(messages) {
  fs.mkdirSync(path.dirname(historyPath), { recursive: true });
  fs.writeFileSync(historyPath, JSON.stringify(messages, null, 2));
}

Append each user message and assistant reply to this array before sending the next request, and pass the full array as messages in the request body. Add a claude reset subcommand that clears the file, and a claude --new flag for one-off prompts that skip history entirely.

Adding tool use (optional but powerful)

If your CLI needs Claude to run commands, query a database, or hit an internal API rather than just answer in text, you'll want tool/function calling. Define a tool schema, pass it in the request, and handle the tool_use block Claude returns by executing the corresponding function locally and sending the result back in a follow-up message. The tools documentation covers the request/response shape in detail — the pattern is the same whether you're calling Claude directly or through a proxy.

Packaging and distribution

Once the tool works locally, publishing it is straightforward:

npm login
npm publish --access public

Users then install it globally with npm install -g your-claude-cli and run it as a normal shell command. Add a postinstall script that prompts for an API key on first run, and a --help output via commander's built-in handling — both make the difference between a tool people use once and one they keep around.

Why route through an API layer instead of raw credentials

Building directly against a model provider's SDK works fine for personal scripts, but if you're distributing the CLI to a team or shipping it as a product, you probably don't want to hand out raw provider credentials to every user. Routing requests through an application-scoped key — like the ones SubToAPI issues — means you can revoke, rate-limit, and monitor usage per CLI installation without touching your underlying Claude access. Pricing starts at €9/month for solo use, with team seats for shared CLI deployments.

questions

Do I need Node.js specifically to build a Claude CLI tool? No — Python, Go, and Rust all work well. Node.js is popular because commander/yargs make argument parsing fast to set up and npm makes distribution trivial, but the HTTP and streaming logic translates directly to any language with an HTTP client.

How do I handle API keys securely in a distributed CLI? Store them in a user-owned config file (e.g. ~/.claude-cli/config.json) or environment variable, never hardcode them in the package, and give users a config set-key command so the key never touches version control.

Can I add streaming output without a framework? Yes. Streaming only requires reading the HTTP response body as a stream and parsing server-sent events as they arrive — no framework is needed, just fetch or an equivalent HTTP client with stream support.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →