TL;DR: CLI chatbot bridging OpenAI’s Responses API with weather and research APIs. Handles streaming, tool-calling,
SIGINTcancellation, retries, and graceful API error handling.
Interactive command-line chat application that:
- Accepts free-text prompts in a REPL-style loop.
- Sends the conversation to OpenAI’s Responses API (
gpt-4.1-nano) with streaming. - Lets the LLM call two external tools — weather and research — backed by a remote API.
- Executes tools, feeds results back to the LLM, and streams the answer.
- Supports cancellation during LLM streaming and tool execution via
SIGINT(Ctrl+C).
All logic lives in cli.ts (~277 lines). Tool definitions are in
tools.ts.
| Category | Technology | Notes |
|---|---|---|
| Runtime | Node.js 24 | Required (engines field in package.json) |
| Package Manager | pnpm 11.5.3 | Enforced via packageManager field |
| Language | TypeScript 6 | Strict mode, ESNext target, ES modules |
| Execution | tsx | Runs .ts files directly without a build step |
| LLM Client | openai v6 | Uses the Responses API (openai.responses.create) |
| HTTP Client | ky v2 | Used for calling the Elyos weather/research API |
| CLI UI | @clack/prompts | Input prompts, logging, and outro |
| Spinner | yocto-spinner | Shown during tool execution (handles SIGINT manually) |
| Validation | arktype v2 | Runtime type checking for env vars and tool arguments |
| Testing | vitest v4 | Minimal — one test file |
| Linting | Biome v2, oxlint | Biome also handles formatting |
| Code Tools | ast-grep, convert-to-arrow | Automated refactoring scripts (strip-braces is a custom ast-grep rule) |
| Variable | Purpose | Validation |
|---|---|---|
OPENAI_API_KEY |
Authenticates with the OpenAI API | Must match regex sk-(proj-)?[a-z0-9_-]{20,} |
ELYOS_API_KEY |
Sent as X-API-Key header to the Elyos API |
Must be non-empty |
# Install dependencies
pnpm install
# Run the CLI (recommended — preserves SIGINT handling)
node --import tsx cli.ts
# Alternative (swallows SIGINT — not recommended)
pnpm tsx cli.tsWhy
node --import tsxinstead ofpnpm tsx?pnpm tsxswallowsSIGINTsignals, breaking cancellation. See Known Issues.
- Project Overview
- Tech Stack
- Prerequisites & Setup
- Project Structure
- Architecture & Design
- Core Modules Deep Dive
- The Conversation Loop
- Tool Calling System
- API Layer & Retry Logic
- Streaming & Cancellation
- Type System & Validation
- Linting, Formatting & Code Style
- Testing
- Known Issues & Gotchas
- Extending the Project
streamwise/
├── cli.ts # Main application — all CLI logic, conversation loop, streaming
├── tools.ts # OpenAI tool/function definitions (get_weather, research_topic)
├── basic.test.ts # Verifies 2 tools exported with correct type
├── package.json # Dependencies, scripts, engine constraints
├── tsconfig.json # TypeScript config (strict, ESNext, noEmit)
├── biome.json # Biome linter/formatter rules
├── .oxlintrc.json # oxlint rules
├── vitest.config.ts # Vitest config (defaults only)
├── pnpm-workspace.yaml # Build allowlist for native packages
├── pnpm-lock.yaml # Lock file
└── README.md # User-facing docs
No src/ directory, no nested modules. Everything is co-located in
the root.
┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ User │────▶│ Clack Input │────▶│ OpenAI LLM │────▶│ Tool Calls? │
│ Input │ │ Prompt │ │ (streaming) │ │ │
└─────────┘ └──────────────┘ └──────────────┘ └──────┬───────┘
│
┌────────┴────────┐
│ │
No tools Has tools
│ │
▼ ▼
Stream text Execute each tool
to terminal via Elyos API
│ │
▼ ▼
Add to Feed results back
conversation to LLM → stream
final answer
- No abstractions. No
Agentclass, noProviderinterface, noToolRunnerregistry. - Not model-agnostic. Uses the
openaipackage and Responses API directly. - Single file for logic.
cli.tscontains everything — types, API clients, conversation loop, tool dispatch, streaming. - Type safety as guardrails.
neverchecks, arktype runtime validation, and discriminated unions make invalid states unrepresentable.
type ResponseInput // OpenAI SDK: full input array
type ResponseInputItem // OpenAI SDK: individual input items
type ResponseOutputItem // OpenAI SDK: individual output items
type CarryForwardItem // Items carried between turns: message | function_call | reasoning
type ToolCallItem // function_call narrowed to get_weather | research_topic
type Message // { role, content }CarryForwardItem determines which output items survive into the next
API call — messages, function calls, and reasoning. Other output types
are dropped.
- Model:
gpt-4.1-nano - Base URL:
https://elyos-interview-907656039105.europe-west2.run.app - Auth:
X-API-Keyheader fromELYOS_API_KEY - Retry (ky built-in): 3 attempts, 1s backoff limit, jitter, retry on timeout
| Function | Purpose |
|---|---|
AssistantMessage(str) |
{ role: "assistant", content: str } |
UserMessage(str) |
{ role: "user", content: str } |
isCarryForwardItem(item) |
Type guard for message/function_call/reasoning |
isToolCallItem(item) |
Type guard for get_weather/research_topic |
getInput() |
Clack text prompt, returns false on cancel |
isExit(msg) |
true for "quit", "exit", "q", "", or false |
OpenAIApiKey (line 48) uses arktype’s "=>" morph syntax:
const OpenAIApiKey = type("string > 20", "=>", type(/^sk-(?:proj-)?[a-z0-9_-]{20,}$/i));JSON argument schemas (lines 104–106) use .pipe() chains:
const JsonArguments = type("string").pipe((v) => JSON.parse(v));
const WeatherArguments = JsonArguments.pipe(type({ location: "string" }));
const ResearchArguments = JsonArguments.pipe(type({ topic: "string" }));These parse JSON strings and validate shape in one expression.
The main() function (lines 246–275) is the entry point:
main()
│
├─ Validate ELYOS_API_KEY (non-empty)
├─ Validate OPENAI_API_KEY (matches sk-* pattern)
├─ Create OpenAI client
├─ Initialize empty conversation[]
│
└─ while (true)
├─ getInput() → text prompt
├─ isExit()? → outro("Goodbye!") → return
│
├─ processInput(openai, prompt, conversation)
│ │
│ ├─ Build currentInput = [...conversation, UserMessage(prompt)]
│ ├─ streamOpenAIResponse() → 1st LLM call (may return tool calls)
│ │
│ ├─ If no tool calls → stream text, add newline, return
│ │
│ ├─ For each tool call:
│ │ ├─ runTool() → calls Elyos API with spinner
│ │ └─ Collect function_call_output items
│ │
│ └─ streamOpenAIResponse() → 2nd LLM call with tool results
│ └─ Stream final answer, return
│
├─ conversation.push(UserMessage(prompt))
└─ conversation.push(AssistantMessage(response))
- First call — user prompt sent; LLM returns function calls.
- Tool execution — each call runs against the Elyos API.
- Second call — conversation + tool outputs sent; LLM streams the final response.
Without tool calls, only one API request is made.
The tools array exports two function definitions for the Responses
API. Both use strict: true (LLM must adhere to the parameter schema
exactly).
- Description: “Get current weather for a city. Fast response.”
- Parameters:
{ location: string }(required)
- Description: “Research a topic in depth. Takes 3-8 seconds. Use for questions requiring detailed research.”
- Parameters:
{ topic: string }(required)
Dispatch is a series of if checks, not a registry:
const runTool = async (tool: ToolCallItem) => {
// 1. Create spinner with handleSignals: false (custom SIGINT handling)
// 2. Create AbortController for cancellation
// 3. Register SIGINT handler to stop spinner + abort
// 4. Dispatch based on tool.name:
// - "get_weather" → runWeather(tool, controller)
// - "research_topic" → runResearch(tool, controller)
// 5. Exhaustiveness check: `const unreachable: never = tool.name`
}The never assignment is an exhaustiveness check — adding a tool to
ToolCallItem without handling it here is a compile error.
Arguments arrive as JSON strings from the LLM. Parsed + validated in one step via arktype:
const args = WeatherArguments.assert(tool.arguments);
// WeatherArguments = type("string") → JSON.parse → type({ location: "string" })If the arguments don’t match, .assert() throws.
| Endpoint | Method | Params | Purpose |
|---|---|---|---|
/weather |
GET | ?location=<city> |
Current weather |
/research |
GET | ?topic=<topic> |
In-depth research (3–8s) |
Layer 1 — ky built-in retries (network level):
retry: {
backoffLimit: 1000, // max 1 second between retries
jitter: true, // randomized backoff
limit: 3, // max 3 attempts
retryOnTimeout: true,
}Handles transient HTTP failures (timeouts, 5xx, network errors).
Layer 2 — Application-level retry (empty response handling):
callApi() wraps every tool API call in a 3-attempt loop:
for (let i = 0; i < 3; i++) {
response = (await callApi()).trim();
if (!response || response === "{}")
log.warn("Received empty response. Will retry");
else return response;
}Catches 200 OK with empty body or {} — a known issue with
/research.
ky errors are caught; their message field is extracted via arktype:
} catch (e) {
const error = type({ message: "string" }).assert(e);
return error.message; // returned as tool output to the LLM
}Error messages become tool output, letting the LLM inform the user instead of crashing the CLI.
Creates an OpenAI streaming response and iterates over events:
const stream = await openai.responses.create(
{ model, input, tools, stream: true },
{ signal: controller.signal }, // AbortController signal
);
for await (const event of stream) {
switch (event.type) {
case "response.output_text.delta":
// Write chunk to stdout immediately + accumulate
process.stdout.write(event.delta);
outputText += event.delta;
break;
case "response.completed":
// Full output array (messages, function_calls, reasoning)
output = event.response.output;
break;
case "response.failed":
// Log error (with fallback message)
break;
}
}Text deltas are written directly to process.stdout — the user sees
text appear in real-time.
Handled at two levels:
process.once("SIGINT", onSigint);
// onSigint: sets cancelled=true, aborts the controller,
// prints "\nCancelled\n"
// ...
process.off("SIGINT", onSigint); // cleanup in finally blockUses process.once — a single SIGINT triggers cancellation. The
listener is removed in the finally block, preventing dangling
listeners across turns.
// In runTool():
process.on("SIGINT", () => {
s.stop("Cancelled");
controller.abort();
});Uses process.on — this listener is never removed (see Known
Issues).
@clack/prompts spinner calls process.exit() on SIGINT, killing the
CLI. yocto-spinner with handleSignals: false gives manual control.
The app narrows OpenAI SDK types:
// From OpenAI SDK
OpenAI.Responses.ResponseInput // Full input array
OpenAI.Responses.ResponseInputItem // Individual input items
OpenAI.Responses.ResponseOutputItem // Individual output items
// Custom narrowed types
CarryForwardItem = Extract<OutputItem, { type: "message" | "function_call" | "reasoning" }>
ToolCallItem = Extract<OutputItem, { type: "function_call" }> & { name: "get_weather" | "research_topic" }Extract narrows the SDK’s broad union types into discriminated unions.
arktype handles runtime validation (env vars, API responses, LLM-generated arguments):
| Schema | Validates | Pattern |
|---|---|---|
OpenAIApiKey |
OPENAI_API_KEY |
type("string > 20", "=>", type(/^sk-.../i)) |
| (inline) | ELYOS_API_KEY |
type("string > 0")(value) |
JsonArguments |
JSON string | type("string").pipe((v) => JSON.parse(v)) |
WeatherArguments |
Tool arguments | JsonArguments.pipe(type({ location: "string" })) |
ResearchArguments |
Tool arguments | JsonArguments.pipe(type({ topic: "string" })) |
.pipe() chains steps — parse JSON, then validate shape. .assert()
throws on failure.
Uses TypeScript’s never type for exhaustiveness (see Section 8):
const unreachable: never = tool.name;
throw unreachable;| Tool | Config | Purpose |
|---|---|---|
| Biome | biome.json |
Linter + formatter. Custom rules (no recommended preset). Enforces useArrowFunction, useConst, noExplicitAny, etc. |
| oxlint | .oxlintrc.json |
Type-aware linter. Rules: no-floating-promises, return-await. Uses oxlint-tsgolint. |
Formatting scripts (pnpm format):
convert-to-arrow— converts function expressions to arrow functionsstrip-braces— custom ast-grep rule removing braces from single-statement blocks- Biome format + check
Run pnpm lint to check, pnpm format to fix.
Single test file: basic.test.ts. Verifies tools array exports 2
function-type tools.
pnpm test # run onceCI runs on PRs: build → lint → test (.github/workflows/ci.yml).
pnpm tsxswallows SIGINT. Usenode --import tsx cli.tsto preserve cancellation.runTool()SIGINT listener never removed. Usesprocess.on(notonce) with no cleanup. Multiple tool calls accumulate listeners.callApi()inner function shadows outer. The innercallApi(line 81) shadows the outercallApi(line 76). Works but confusing./researchreturns empty bodies. The app retries up to 3 times on empty/{}responses.
Add a new tool:
- Add definition to
tools.ts(follow existing pattern). - Add tool name to
ToolCallItemunion incli.ts. - Update
isToolCallItem()type guard. - Add
run<Name>()function and dispatch branch inrunTool(). - Add arktype argument schema.
- The
neverexhaustiveness check will error if step 4 is missed.
Swap the model: Change const model (line 23). Ensure the model
supports the Responses API and tool calling.