Skip to content
AI Full-Stack Wiki

Tutorial: Build an ask Command-Line Tool on the Claude API

Build a Unix-friendly CLI for Claude: flags and piped input, every stop reason handled, typed errors mapped to exit codes, and the answer kept separate from diagnostics.

Difficulty: Beginner18 min readUpdated
On this page· What you will build

Why this matters

Your first API call takes a request and a response apart field by field. The quickest way to make that knowledge stick is to build a tool you will actually use: ask, a command that sends a question to Claude from your terminal. Done properly, it behaves like any good Unix tool. It reads from pipes, keeps the answer separate from diagnostics, and exits with a code a script can act on. Every one of those properties comes straight from handling the response and its errors correctly.

What you will build#

ask "What is a race condition?"
git diff | ask "Write a commit message for this diff"
cat error.log | ask -s "You are a senior SRE" --effort medium "What went wrong?" > diagnosis.md
  • Flags for the system prompt, model, effort, and output limit, validated with node:util's parseArgs.
  • Piped input wrapped in tags, with the instruction from the arguments after it.
  • Every stop reason handled: a cut-off answer still prints, but exits with its own code.
  • Typed errors mapped to exit codes, and the answer alone on stdout.
ask command-line toolThe ask CLI takes a question from its arguments and optional piped input, calls Claude, writes only the answer to stdout, and reports usage, errors, and a meaningful exit code on stderr.Shellaskargs + stdinClaudestdoutanswer onlystderr+ exit code

Hover or tap a component to see what it does.

The ask CLI: arguments and piped input go to Claude; the answer goes to stdout, diagnostics and a meaningful exit code to stderr.
Show text description

Separating the answer from diagnostics is what makes a command-line tool composable in pipes and scripts.

  • Shell: You, or a script: arguments plus optional piped input.
  • ask: Validates flags, wraps piped input in tags, and calls the model once.
  • Claude: Returns content blocks, a stop reason, usage, and a request ID.
  • stdout: Only the answer, so redirects and pipes capture clean output.
  • stderr: Usage line, error messages, and an exit code per failure kind, for scripts to branch on.

You need Node.js 22.18 or later and an Anthropic API key in ANTHROPIC_API_KEY.

1. Set up the project#

mkdir ask-cli && cd ask-cli
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk
npm install -D typescript @types/node
mkdir src test
npm pkg set scripts.test="node --test" scripts.typecheck="tsc" bin.ask="src/ask.ts"

Copy the tsconfig.json from Foundations Tutorial 1. The bin entry is what lets npm link install the command later.

The client module is the one from the article:

src/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
 
export const anthropic = new Anthropic({
  timeout: 60_000, // milliseconds
  maxRetries: 2, // retries 408, 409, 429, 5xx, and connection errors with backoff
});
 
export const MODEL = "claude-opus-5-5";

2. Turn a response into an answer, or a typed error#

The article's answerText throws when there is no finished answer. This version keeps the partial text of a cut-off answer, because a CLI can still show it, and adds a function that maps every failure to an exit code.

src/answer.ts
import Anthropic from "@anthropic-ai/sdk";
 
const textOf = (message: Anthropic.Message) =>
  message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
 
/** The answer was cut off. Carries the partial text so the caller can still show it. */
export class IncompleteAnswerError extends Error {
  readonly partial: string;
  constructor(partial: string) {
    super("The answer was cut off at max_tokens");
    this.partial = partial;
  }
}
export class RefusedError extends Error {}
 
/** The text of a finished answer, or a typed error explaining why there is none. */
export function answerText(message: Anthropic.Message): string {
  switch (message.stop_reason) {
    case "end_turn":
    case "stop_sequence":
      return textOf(message);
    case "max_tokens":
      throw new IncompleteAnswerError(textOf(message));
    case "refusal":
      throw new RefusedError(message.stop_details?.explanation ?? "The model declined this request");
    default:
      // tool_use and pause_turn need a tool loop; this CLI sends no tools, so they mean a bug.
      throw new Error(`Unexpected stop_reason: ${String(message.stop_reason)}`);
  }
}
 
/** Exit codes a shell script can branch on. 0 and 1 follow convention; the rest are ours. */
export const EXIT = { ok: 0, failed: 1, usage: 2, incomplete: 3, refused: 4, unavailable: 5, auth: 6 } as const;
 
/** Map any failure to an exit code and a one-line explanation. Most specific class first. */
export function classify(error: unknown): { code: number; message: string } {
  if (error instanceof IncompleteAnswerError) return { code: EXIT.incomplete, message: "answer cut off at max_tokens; raise --max-tokens" };
  if (error instanceof RefusedError) return { code: EXIT.refused, message: `refused: ${error.message}` };
  if (error instanceof Anthropic.AuthenticationError || error instanceof Anthropic.PermissionDeniedError) {
    return { code: EXIT.auth, message: "authentication failed: check ANTHROPIC_API_KEY" };
  }
  if (error instanceof Anthropic.RateLimitError) return { code: EXIT.unavailable, message: "rate limited even after retries; try again shortly" };
  if (error instanceof Anthropic.InternalServerError) return { code: EXIT.unavailable, message: `API unavailable (${error.status}) even after retries` };
  if (error instanceof Anthropic.APIConnectionError) return { code: EXIT.unavailable, message: "could not reach the API" };
  if (error instanceof Anthropic.APIError) {
    // error.error is the parsed JSON body: { type: "error", error: { type, message } }
    const detail = (error.error as { error?: { message?: string } } | undefined)?.error?.message ?? error.message;
    return { code: EXIT.failed, message: `API error ${error.status}: ${detail}` };
  }
  return { code: EXIT.failed, message: error instanceof Error ? error.message : String(error) };
}

IncompleteAnswerError assigns partial in the constructor body rather than with a TypeScript parameter property (constructor(readonly partial: string)). Parameter properties generate code, so Node.js's type stripping cannot run them, and the erasableSyntaxOnly option in tsconfig.json rejects them for exactly that reason.

The order in classify matters: in the TypeScript SDK every error class here is an APIError, including APIConnectionError, so the specific checks must come before the general one. A 529 "overloaded" arrives as an InternalServerError, after the SDK's own retries.

Exit codeMeaningA script might…
0Answer printedContinue
1Other API error (bad model ID, invalid request)Stop and report
2Usage error (bad flag, nothing to ask)Fix the command
3Cut off at max_tokens; partial answer printedRetry with a higher limit
4RefusedRoute to a person
5Rate limited, overloaded, or unreachable after retriesRetry later
6Authentication or permission failureCheck the key

3. The command#

src/ask.ts
#!/usr/bin/env node
import { text } from "node:stream/consumers";
import { parseArgs } from "node:util";
import { anthropic, MODEL } from "./anthropic.ts";
import { answerText, classify, EXIT, IncompleteAnswerError } from "./answer.ts";
 
const USAGE = `usage: ask [options] <question...>
       cat file.txt | ask [options] <instruction...>
 
  -s, --system <text>     system prompt
  -m, --model <id>        model (default ${MODEL})
  -e, --effort <level>    low | medium | high | xhigh | max (default low)
      --max-tokens <n>    output limit, thinking included (default 4096)
      --json              print the raw response as JSON
  -h, --help              show this help`;
 
const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
type Effort = (typeof EFFORTS)[number];
 
function fail(code: number, message: string): never {
  console.error(`ask: ${message}`);
  process.exit(code);
}
 
let args;
try {
  args = parseArgs({
    allowPositionals: true,
    options: {
      system: { type: "string", short: "s" },
      model: { type: "string", short: "m", default: MODEL },
      effort: { type: "string", short: "e", default: "low" },
      "max-tokens": { type: "string", default: "4096" },
      json: { type: "boolean", default: false },
      help: { type: "boolean", short: "h", default: false },
    },
  });
} catch (error) {
  fail(EXIT.usage, `${(error as Error).message}\n\n${USAGE}`);
}
const { values, positionals } = args;
if (values.help) {
  console.log(USAGE);
  process.exit(EXIT.ok);
}
 
const effort = values.effort as Effort;
if (!EFFORTS.includes(effort)) fail(EXIT.usage, `--effort must be one of ${EFFORTS.join(", ")}`);
const maxTokens = Number(values["max-tokens"]);
if (!Number.isInteger(maxTokens) || maxTokens < 1) fail(EXIT.usage, "--max-tokens must be a positive integer");
 
// Piped input becomes a tagged document, with the instruction from the arguments after it.
const piped = process.stdin.isTTY ? "" : (await text(process.stdin)).trim();
const instruction = positionals.join(" ").trim();
const prompt = [piped && `<input>\n${piped}\n</input>`, instruction].filter(Boolean).join("\n\n");
if (!prompt) fail(EXIT.usage, `nothing to ask\n\n${USAGE}`);
 
try {
  const message = await anthropic.messages.create({
    model: values.model,
    max_tokens: maxTokens,
    output_config: { effort },
    ...(values.system ? { system: values.system } : {}),
    messages: [{ role: "user", content: prompt }],
  });
 
  // Diagnostics go to stderr, so `ask ... > answer.txt` captures only the answer.
  const u = message.usage;
  console.error(`[${message.model} · in ${u.input_tokens} · out ${u.output_tokens} · ${message.stop_reason} · ${message._request_id ?? "no request id"}]`);
 
  if (values.json) {
    console.log(JSON.stringify(message, null, 2));
    process.exit(EXIT.ok);
  }
  console.log(answerText(message));
} catch (error) {
  if (error instanceof IncompleteAnswerError && error.partial) console.log(error.partial);
  const { code, message } = classify(error);
  fail(code, message);
}

A few details carry most of the value:

  • Long input first, instruction last. Piped text goes inside <input> tags before the question, the order the prompt engineering article recommends for long context.
  • process.stdin.isTTY tells a terminal from a pipe. When stdin is not a terminal, the tool reads it to the end, like cat or grep. In a script or cron job with nothing to pipe, run ask "..." < /dev/null so it does not wait for input that never comes.
  • The request ID goes to stderr on every call. It is the one value Anthropic support needs to look up a specific request.

4. Test the mapping without the network#

test/answer.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { answerText, classify, EXIT, IncompleteAnswerError, RefusedError } from "../src/answer.ts";
 
// A minimal response, shaped like the API's, for one stop reason.
function message(stop_reason: Anthropic.StopReason, text = "Hello"): Anthropic.Message {
  return {
    id: "msg_test", type: "message", role: "assistant", model: "claude-opus-5-5", container: null,
    content: [{ type: "text", text, citations: null }],
    stop_reason, stop_sequence: null, stop_details: null,
    usage: { input_tokens: 1, output_tokens: 1 } as Anthropic.Usage,
  } as Anthropic.Message;
}
 
test("a finished answer returns its text", () => {
  assert.equal(answerText(message("end_turn")), "Hello");
});
 
test("a cut-off answer throws, and keeps the partial text", () => {
  assert.throws(() => answerText(message("max_tokens", "Half an ans")), (e) => e instanceof IncompleteAnswerError && e.partial === "Half an ans");
});
 
test("a refusal throws RefusedError", () => {
  assert.throws(() => answerText(message("refusal")), RefusedError);
});
 
test("tool_use is a bug for a CLI that sends no tools", () => {
  assert.throws(() => answerText(message("tool_use")), /Unexpected stop_reason/);
});
 
test("API errors map to exit codes a script can branch on", () => {
  const headers = new Headers();
  assert.equal(classify(new Anthropic.AuthenticationError(401, undefined, "bad key", headers)).code, EXIT.auth);
  assert.equal(classify(new Anthropic.RateLimitError(429, undefined, "slow down", headers)).code, EXIT.unavailable);
  assert.equal(classify(new Anthropic.InternalServerError(529, undefined, "overloaded", headers)).code, EXIT.unavailable);
  assert.equal(classify(new Anthropic.APIConnectionError({ message: "offline" })).code, EXIT.unavailable);
  assert.equal(classify(new Anthropic.BadRequestError(400, undefined, "bad request", headers)).code, EXIT.failed);
  assert.equal(classify(new IncompleteAnswerError("x")).code, EXIT.incomplete);
});
npm test          # 5 passing
npm run typecheck

5. Use it#

Install the command globally from the project folder, then try each path:

npm link    # puts `ask` on your PATH; `npm unlink -g ask-cli` removes it
ask "What is a race condition? Two sentences."
example output (the answer will differ)
[claude-opus-5-5 · in 18 · out 61 · end_turn · req_011CSHo…]
A race condition happens when the result of a program depends on the timing of operations that run
concurrently, such as two requests updating the same record. It is fixed by making the operations
atomic or ordering them explicitly.

Everything below the first line is stdout. Check the plumbing:

ask "Name three HTTP methods" 2>/dev/null           # only the answer
git log --oneline -20 | ask "Summarize what changed this week"
ask --max-tokens 40 "Explain distributed consensus in detail"; echo "exit: $?"
ask -e turbo hi; echo "exit: $?"                     # ask: --effort must be one of ...  exit: 2
ask -m claude-nope hi; echo "exit: $?"               # ask: API error 404: model: claude-nope  exit: 1

The --max-tokens 40 run prints whatever fit, then ask: answer cut off at max_tokens; raise --max-tokens, and exits with 3. Because thinking also counts toward max_tokens, a very low limit can leave no visible text at all; the exit code still tells the script what happened.

Common mistakes#

  • Printing diagnostics to stdout. Usage lines and warnings end up inside files and pipes. Send everything except the answer to stderr.
  • Treating every non-zero exit as the same failure. "Retry later", "fix your key", and "this was refused" need different handling. Give them different codes.
  • Ignoring stop_reason. A cut-off answer looks complete to code that only reads the text.
  • Catching APIError first. It is the parent class; the specific branches below it never run.
  • Waiting on stdin in a script. When nothing is piped and stdin is not a terminal, the tool waits forever. Redirect from /dev/null.

Try it

Extend ask

  1. Add --stream: use anthropic.messages.stream() and print text deltas as they arrive, keeping the usage line on stderr at the end.
  2. Add -f, --file <path> as an alternative to piping, and refuse files above a size you choose before sending anything.
  3. Add a --cost flag that prints the dollar cost of the call to stderr, using the prices from the API keys and billing article.
  4. Write a shell script that calls ask in a loop over files and retries only when the exit code is 5.

Check your understanding

Why does ask print the usage line to stderr instead of stdout?
The answer stops mid-sentence and stop_reason is max_tokens. What does ask do?
Why must classify check RateLimitError before APIError?

Key takeaways

  • A response is not an answer until you have checked stop_reason; turn each reason into either text or a typed error.
  • Map the SDK's typed errors, most specific first, to outcomes a caller can act on.
  • Keep the answer on stdout and everything else on stderr, and give each failure kind its own exit code.
  • Log the request ID of every call; it is how a specific request is traced later.

Next steps#

Finished this article? Track it to see your progress on the roadmap.