Part 3: Configuration Management
Part 2 hardcoded everything — OpenAI, one model, one env var. That works for a demo but not for a real tool. This part introduces persisted configuration: a provider/model catalog, a config wizard, and API key resolution that works across providers.
In this part, we're going to make it possible to configure protoagent, mainly with the introduction of protoagent.jsonc configuration file. It will allow you to specify LLM providers and other configurations in the future, which will be automatically used by protoagent. protoagent will look for this protoagent.jsonc file either within the directory/project it is being run, or at configurations in the user directory. The paths will be one of the following:
<process.cwd()>/.protoagent/protoagent.jsonc(project config)~/.config/protoagent/protoagent.jsonc(user config)
Project config takes precedence, allowing per-project overrides of user defaults.
Your target snapshot is protoagent-build-your-own-checkpoints/part-3.
What you are building
- A provider/model catalog with pricing metadata (
src/providers.ts) - Persistent config storage in
protoagent.jsonc(src/config.tsx) - A
protoagent configuresubcommand for the setup wizard - Inline first-time setup in the main app
- API key resolution: active
protoagent.jsonc→ environment variable → provider default
Note on configure vs init: Part 3 focuses on the configure command (interactive setup wizard that modifies the active config). Later parts introduce the init command, which creates an initial empty config template. You can use them together: init creates the file structure, then configure sets the active provider/model.
Files to create or change
| File | Action |
|---|---|
src/providers.ts | Create — provider/model registry |
src/config.tsx | Create — config persistence + setup wizard |
src/cli.tsx | Modify — add configure subcommand |
src/App.tsx | Modify — load config, build client, inline setup |
package.json | Modify — add jsonc-parser dependency |
Step 1: Update package.json
Add jsonc-parser (used later for runtime config, but good to include now):
{
"name": "protoagent",
"version": "0.0.1",
"description": "A simple coding agent CLI.",
"bin": "dist/cli.js",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.tsx",
"build:watch": "tsc --watch"
},
"files": [
"dist"
],
"author": "",
"license": "ISC",
"dependencies": {
"@inkjs/ui": "^2.0.0",
"commander": "^14.0.1",
"ink": "^6.7.0",
"ink-big-text": "^2.0.0",
"jsonc-parser": "^3.3.1",
"openai": "^5.23.1",
"react": "^19.1.1"
},
"devDependencies": {
"@types/node": "^24.5.2",
"@types/react": "^19.1.15",
"tsx": "^4.20.6",
"typescript": "^5.9.2"
}
}Note: we dropped dotenv — API keys are now resolved through the config system or environment variables directly.
Step 2: Create src/providers.ts
Create the file:
touch src/providers.tsThis file defines the built-in provider catalog. Every supported provider, model, pricing, and connection details live here.
export interface ModelDetails {
id: string;
name: string;
contextWindow: number;
pricingPerMillionInput: number;
pricingPerMillionOutput: number;
defaultParams?: Record<string, unknown>;
}
export interface ModelProvider {
id: string;
name: string;
baseURL?: string;
apiKey?: string;
apiKeyEnvVar?: string;
headers?: Record<string, string>;
defaultParams?: Record<string, unknown>;
models: ModelDetails[];
}
export const BUILTIN_PROVIDERS: ModelProvider[] = [
{
id: 'openai',
name: 'OpenAI',
apiKeyEnvVar: 'OPENAI_API_KEY',
models: [
{ id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 1_048_576, pricingPerMillionInput: 2.50, pricingPerMillionOutput: 15.00 },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', contextWindow: 1_000_000, pricingPerMillionInput: 0.25, pricingPerMillionOutput: 2.00 },
{ id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 1_048_576, pricingPerMillionInput: 2.0, pricingPerMillionOutput: 8.00 },
],
},
{
id: 'anthropic',
name: 'Anthropic Claude',
baseURL: 'https://api.anthropic.com/v1/',
apiKeyEnvVar: 'ANTHROPIC_API_KEY',
models: [
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6', contextWindow: 1_000_000, pricingPerMillionInput: 5.0, pricingPerMillionOutput: 25.0 },
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextWindow: 1_000_000, pricingPerMillionInput: 3.0, pricingPerMillionOutput: 15.0 },
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', contextWindow: 200_000, pricingPerMillionInput: 1.0, pricingPerMillionOutput: 5.0 },
],
},
{
id: 'google',
name: 'Google Gemini',
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
apiKeyEnvVar: 'GEMINI_API_KEY',
models: [
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 0.50, pricingPerMillionOutput: 3.0 },
{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 2.0, pricingPerMillionOutput: 12.0 },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1_000_000, pricingPerMillionInput: 0.30, pricingPerMillionOutput: 2.5 },
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1_000_000, pricingPerMillionInput: 1.25, pricingPerMillionOutput: 10.0 },
],
}
];
export function getAllProviders(): ModelProvider[] {
return BUILTIN_PROVIDERS;
}
export function getProvider(providerId: string): ModelProvider | undefined {
return getAllProviders().find((provider) => provider.id === providerId);
}
export function getModelDetails(providerId: string, modelId: string): ModelDetails | undefined {
return getProvider(providerId)?.models.find((model) => model.id === modelId);
}
export function getModelPricing(providerId: string, modelId: string) {
const details = getModelDetails(providerId, modelId);
if (!details) return undefined;
return {
inputPerToken: details.pricingPerMillionInput / 1_000_000,
outputPerToken: details.pricingPerMillionOutput / 1_000_000,
contextWindow: details.contextWindow ?? 128_000,
};
}
export function getRequestDefaultParams(providerId: string, modelId: string): Record<string, unknown> {
const provider = getProvider(providerId);
const model = getModelDetails(providerId, modelId);
return {
...(provider?.defaultParams || {}),
...(model?.defaultParams || {}),
};
}Note: getAllProviders() just returns the built-in list for now. In a later part, we'll add runtime config loading from the active protoagent.jsonc so users can add custom providers via that file.
Step 3: Create src/config.tsx
Create the file:
touch src/config.tsxThis file handles config persistence and the setup wizard. The active provider/model/API key selection is stored in protoagent.jsonc, using the project file if present and otherwise the shared user file. Configuration is read from and written to protoagent.jsonc using jsonc-parser.
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import { Select, TextInput, PasswordInput } from '@inkjs/ui';
import { parse } from 'jsonc-parser';
import { getAllProviders, getProvider } from './providers.js';
export interface Config {
provider: string;
model: string;
apiKey?: string;
}
export type InitConfigTarget = 'project' | 'user';
export type InitConfigWriteStatus = 'created' | 'exists' | 'overwritten';
// These constants define Unix file permissions in octal notation.
// They ensure config directories and files are only accessible by the owner,
// protecting sensitive data like API keys from other users on the system.
const CONFIG_DIR_MODE = 0o700; // Owner: rwx, Group: ---, Others: ---
const CONFIG_FILE_MODE = 0o600; // Owner: rw-, Group: ---, Others: ---
// Applies restrictive Unix permissions to a file or directory.
// Skips on Windows since Unix permission concepts don't apply there.
// Uses chmodSync to enforce the permission mode immediately.
function hardenPermissions(targetPath: string, mode: number): void {
if (process.platform === 'win32') return;
chmodSync(targetPath, mode);
}
// Resolves the API key for a provider using a precedence chain:
// 1. Direct API key from config
// 2. Environment variable specific to the provider (e.g., OPENAI_API_KEY)
// 3. Generic PROTOAGENT_API_KEY environment variable
// 4. Default API key from provider definition
// 5. 'none' if provider uses header-based auth instead of API key
// Returns null if no API key could be resolved.
export function resolveApiKey(config: Pick<Config, 'provider' | 'apiKey'>): string | null {
const directApiKey = config.apiKey?.trim();
if (directApiKey) return directApiKey;
const provider = getProvider(config.provider);
if (provider?.apiKeyEnvVar) {
const envValue = process.env[provider.apiKeyEnvVar]?.trim();
if (envValue) return envValue;
}
const envOverride = process.env.PROTOAGENT_API_KEY?.trim();
if (envOverride) return envOverride;
const providerApiKey = provider?.apiKey?.trim();
if (providerApiKey) return providerApiKey;
if (provider?.headers && Object.keys(provider.headers).length > 0) {
return 'none';
}
return null;
}
export const getUserRuntimeConfigDirectory = () => {
const homeDir = os.homedir();
if (process.platform === 'win32') {
return path.join(homeDir, 'AppData', 'Local', 'protoagent');
}
return path.join(homeDir, '.config', 'protoagent');
};
export const getUserRuntimeConfigPath = () => {
return path.join(getUserRuntimeConfigDirectory(), 'protoagent.jsonc');
};
export const getProjectRuntimeConfigDirectory = (cwd = process.cwd()) => {
return path.join(cwd, '.protoagent');
};
export const getProjectRuntimeConfigPath = (cwd = process.cwd()) => {
return path.join(getProjectRuntimeConfigDirectory(cwd), 'protoagent.jsonc');
};
export const getInitConfigPath = (target: InitConfigTarget, cwd = process.cwd()) => {
return target === 'project' ? getProjectRuntimeConfigPath(cwd) : getUserRuntimeConfigPath();
};
/** Returns the active config path: project if it exists, otherwise user. */
export function getActiveRuntimeConfigPath(): string | null {
const projectPath = getProjectRuntimeConfigPath();
if (existsSync(projectPath)) return projectPath;
const userPath = getUserRuntimeConfigPath();
if (existsSync(userPath)) return userPath;
return null;
}
interface RuntimeProviderConfig {
apiKey?: string;
models?: Record<string, unknown>;
}
interface RuntimeConfigFile {
providers?: Record<string, RuntimeProviderConfig>;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
// Reads and parses protoagent.jsonc (with comments support), returns null on error/missing file.
function readRuntimeConfigFileSync(configPath: string): RuntimeConfigFile | null {
if (!existsSync(configPath)) return null;
try {
const content = readFileSync(configPath, 'utf8');
const errors: Array<{ error: number; offset: number; length: number }> = [];
const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0 || !isPlainObject(parsed)) return null;
return parsed as RuntimeConfigFile;
} catch {
return null;
}
}
// Extracts the first configured provider/model from runtime config (returns null if none found).
function getConfiguredProviderAndModel(runtimeConfig: RuntimeConfigFile): Config | null {
for (const [providerId, providerConfig] of Object.entries(runtimeConfig.providers || {})) {
const modelId = Object.keys(providerConfig.models || {})[0];
if (!modelId) continue;
const apiKey = typeof providerConfig.apiKey === 'string' && providerConfig.apiKey.trim().length > 0
? providerConfig.apiKey.trim()
: undefined;
return { provider: providerId, model: modelId, ...(apiKey ? { apiKey } : {}) };
}
return null;
}
// Creates config directory with secure permissions if it doesn't exist.
function ensureDirectory(targetDir: string): void {
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true, mode: CONFIG_DIR_MODE });
}
hardenPermissions(targetDir, CONFIG_DIR_MODE);
}
function writeRuntimeConfigFile(configPath: string, runtimeConfig: RuntimeConfigFile): void {
ensureDirectory(path.dirname(configPath));
writeFileSync(configPath, `${JSON.stringify(runtimeConfig, null, 2)}\n`, { encoding: 'utf8', mode: CONFIG_FILE_MODE });
hardenPermissions(configPath, CONFIG_FILE_MODE);
}
// Updates or inserts a provider/model selection into runtime config, preserving existing settings.
function upsertSelectedConfig(runtimeConfig: RuntimeConfigFile, config: Config): RuntimeConfigFile {
const existingProviders = runtimeConfig.providers || {};
const currentProvider = existingProviders[config.provider] || {};
const currentModels = currentProvider.models || {};
const selectedModelConfig = currentModels[config.model] || {};
const nextProvider: RuntimeProviderConfig = {
...currentProvider,
...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
models: Object.fromEntries([
[config.model, selectedModelConfig],
...Object.entries(currentModels).filter(([modelId]) => modelId !== config.model),
]),
};
if (!config.apiKey?.trim()) {
delete nextProvider.apiKey;
}
return {
...runtimeConfig,
providers: Object.fromEntries([
[config.provider, nextProvider],
...Object.entries(existingProviders).filter(([providerId]) => providerId !== config.provider),
]),
};
}
export const readConfig = (target: InitConfigTarget | 'active' = 'active', cwd = process.cwd()): Config | null => {
const configPath = target === 'active' ? getActiveRuntimeConfigPath() : getInitConfigPath(target, cwd);
if (!configPath) return null;
const runtimeConfig = readRuntimeConfigFileSync(configPath);
if (!runtimeConfig) return null;
return getConfiguredProviderAndModel(runtimeConfig);
};
export const writeConfig = (config: Config, target: InitConfigTarget = 'user', cwd = process.cwd()) => {
const configPath = getInitConfigPath(target, cwd);
const runtimeConfig = readRuntimeConfigFileSync(configPath) || { providers: {} };
const nextRuntimeConfig = upsertSelectedConfig(runtimeConfig, config);
writeRuntimeConfigFile(configPath, nextRuntimeConfig);
return configPath;
};
// React component for Configure Wizard (standalone subcommand)
// Guides users through selecting a provider/model and saving it to config
// Steps:
// 1. Choose project vs user config
// 2. If existing config found, show it and ask to reset or keep
// 3. If resetting or no existing config, show provider/model selection
// 4. After selection, prompt for API key (if needed) and save config
export const ConfigureComponent = () => {
const [step, setStep] = useState(0);
const [target, setTarget] = useState<InitConfigTarget>('user');
const [existingConfig, setExistingConfig] = useState<Config | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState('');
const [selectedModelId, setSelectedModelId] = useState('');
const [configWritten, setConfigWritten] = useState(false);
// Step 0: Choose project vs user config
if (step === 0) {
return (
<Box flexDirection="column">
<Text>Choose where to configure ProtoAgent:</Text>
<Box marginTop={1}>
<Select
options={[
{ label: `Project config — ${getProjectRuntimeConfigPath()}`, value: 'project' },
{ label: `Shared user config — ${getUserRuntimeConfigPath()}`, value: 'user' },
]}
onChange={(value) => {
setTarget(value as InitConfigTarget);
const existing = readConfig(value as InitConfigTarget);
setExistingConfig(existing);
setStep(existing ? 1 : 2);
}}
/>
</Box>
</Box>
);
}
// Step 1: Existing config found — ask to reset
if (step === 1 && existingConfig) {
const provider = getProvider(existingConfig.provider);
return (
<Box flexDirection="column">
<Text>Existing configuration found:</Text>
<Text> Provider: {provider?.name || existingConfig.provider}</Text>
<Text> Model: {existingConfig.model}</Text>
<Text> </Text>
<Text>Do you want to reset and configure a new one? (y/n)</Text>
<TextInput
onSubmit={(answer: string) => {
if (answer.toLowerCase() === 'y') {
setStep(2);
} else {
setConfigWritten(false);
setStep(4);
}
}}
/>
</Box>
);
}
// Step 2: Model selection
if (step === 2) {
const items = getAllProviders().flatMap((provider) =>
provider.models.map((model) => ({
label: `${provider.name} - ${model.name}`,
value: `${provider.id}:::${model.id}`,
})),
);
return (
<Box flexDirection="column">
<Text>Select an AI Model:</Text>
<Select
options={items}
onChange={(value: string) => {
const [providerId, modelId] = value.split(':::');
setSelectedProviderId(providerId);
setSelectedModelId(modelId);
setStep(3);
}}
/>
</Box>
);
}
// Step 3: API key input
if (step === 3) {
const provider = getProvider(selectedProviderId);
const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
return (
<Box flexDirection="column">
<Text>{hasResolvedAuth ? 'Optional API Key' : 'Enter API Key'} for {provider?.name || selectedProviderId}:</Text>
<PasswordInput
placeholder={hasResolvedAuth ? 'Press enter to keep resolved auth' : `Enter your ${provider?.apiKeyEnvVar || 'API'} key`}
onSubmit={(value: string) => {
if (value.trim().length === 0 && !hasResolvedAuth) return;
const newConfig: Config = {
provider: selectedProviderId,
model: selectedModelId,
...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
};
writeConfig(newConfig, target);
setConfigWritten(true);
setStep(4);
}}
/>
</Box>
);
}
// Step 4: Done
return (
<Box flexDirection="column">
{configWritten ? (
<Text color="green">Configuration saved successfully!</Text>
) : (
<Text color="yellow">Configuration not changed.</Text>
)}
<Text>You can now run ProtoAgent.</Text>
</Box>
);
};Step 4: Update src/cli.tsx
Add the configure subcommand and pass options to App:
#!/usr/bin/env node
import process from 'node:process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import React from 'react';
import { render } from 'ink';
import { Command } from 'commander';
import { App } from './App.js';
import { ConfigureComponent, readConfig, writeConfig } from './config.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageJson: { version: string } = JSON.parse(
readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
);
const program = new Command();
program
.description('ProtoAgent — a simple, hackable coding agent CLI')
.version(packageJson.version)
.action(() => {
render(<App />);
});
program
.command('configure')
.description('Configure AI model and API key settings')
.option('--project', 'Write <cwd>/.protoagent/protoagent.jsonc')
.option('--user', 'Write the shared user protoagent.jsonc')
.option('--provider <id>', 'Provider id to configure')
.option('--model <id>', 'Model id to configure')
.option('--api-key <key>', 'Explicit API key to store in protoagent.jsonc')
.action((options) => {
if (options.project || options.user || options.provider || options.model || options.apiKey) {
if (options.project && options.user) {
console.error('Choose only one of --project or --user.');
process.exitCode = 1;
return;
}
if (!options.provider || !options.model) {
console.error('Non-interactive configure requires --provider and --model.');
process.exitCode = 1;
return;
}
const target = options.project ? 'project' : 'user';
const resultPath = writeConfig(
{
provider: options.provider,
model: options.model,
...(typeof options.apiKey === 'string' && options.apiKey.trim() ? { apiKey: options.apiKey.trim() } : {}),
},
target,
);
console.log('Configured ProtoAgent:');
console.log(resultPath);
const selected = readConfig(target);
if (selected) {
console.log(`${selected.provider} / ${selected.model}`);
}
return;
}
render(<ConfigureComponent />);
});
program.parse(process.argv);Step 5: Rewrite src/App.tsx
Now the app loads config on startup, builds an OpenAI client from provider metadata, shows inline setup if no config exists, and streams responses using the configured model.
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import { TextInput, Select, PasswordInput } from '@inkjs/ui';
import BigText from 'ink-big-text';
import { OpenAI } from 'openai';
import { readConfig, writeConfig, resolveApiKey, type Config } from './config.js';
import { getAllProviders, getProvider } from './providers.js';
interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
}
function buildClient(config: Config): OpenAI {
const provider = getProvider(config.provider);
const apiKey = resolveApiKey(config);
if (!apiKey) {
const providerName = provider?.name || config.provider;
const envVar = provider?.apiKeyEnvVar;
throw new Error(
envVar
? `Missing API key for ${providerName}. Set it in config or export ${envVar}.`
: `Missing API key for ${providerName}.`
);
}
const clientOptions: ConstructorParameters<typeof OpenAI>[0] = { apiKey };
const baseURL = provider?.baseURL;
if (baseURL) clientOptions.baseURL = baseURL;
if (provider?.headers && Object.keys(provider.headers).length > 0) {
clientOptions.defaultHeaders = provider.headers;
}
return new OpenAI(clientOptions);
}
// If protoagent isn't already set up with LLM provider/model, guide the user through an interactive setup flow
const InlineSetup: React.FC<{ onComplete: (config: Config) => void }> = ({ onComplete }) => {
const [setupStep, setSetupStep] = useState<'provider' | 'api_key'>('provider');
const [selectedProviderId, setSelectedProviderId] = useState('');
const [selectedModelId, setSelectedModelId] = useState('');
const providerItems = getAllProviders().flatMap((provider) =>
provider.models.map((model) => ({
label: `${provider.name} - ${model.name}`,
value: `${provider.id}:::${model.id}`,
})),
);
if (setupStep === 'provider') {
return (
<Box flexDirection="column" marginTop={1}>
<Text color="yellow" bold>First-time setup</Text>
<Text dimColor>Select a provider and model:</Text>
<Box marginTop={1}>
<Select
options={providerItems}
onChange={(value: string) => {
const [providerId, modelId] = value.split(':::');
setSelectedProviderId(providerId);
setSelectedModelId(modelId);
setSetupStep('api_key');
}}
/>
</Box>
</Box>
);
}
const provider = getProvider(selectedProviderId);
const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
return (
<Box flexDirection="column" marginTop={1}>
<Text color="yellow" bold>First-time setup</Text>
<Text dimColor>Selected: {provider?.name} / {selectedModelId}</Text>
<Text>{hasResolvedAuth ? 'Optional API key:' : 'Enter your API key:'}</Text>
<PasswordInput
placeholder={hasResolvedAuth ? 'Press enter to keep resolved auth' : `Paste your ${provider?.apiKeyEnvVar || 'API'} key`}
onSubmit={(value) => {
if (value.trim().length === 0 && !hasResolvedAuth) return;
const newConfig: Config = {
provider: selectedProviderId,
model: selectedModelId,
...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
};
writeConfig(newConfig, 'user');
onComplete(newConfig);
}}
/>
</Box>
);
};
// Main Protoagent app
export const App: React.FC = () => {
const { exit } = useApp();
const [config, setConfig] = useState<Config | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [inputKey, setInputKey] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
const [needsSetup, setNeedsSetup] = useState(false);
const [client, setClient] = useState<OpenAI | null>(null);
const initializeWithConfig = useCallback((loadedConfig: Config) => {
setConfig(loadedConfig);
try {
const newClient = buildClient(loadedConfig);
setClient(newClient);
setMessages([
{ role: 'system', content: 'You are ProtoAgent, a helpful AI coding assistant.' },
]);
setNeedsSetup(false);
setInitialized(true);
} catch (err: any) {
setError(err.message);
}
}, []);
// When the app loads, read the configuration file
useEffect(() => {
const loadedConfig = readConfig();
if (!loadedConfig) {
setNeedsSetup(true);
return;
}
initializeWithConfig(loadedConfig);
}, [initializeWithConfig]);
const handleSubmit = useCallback(async (value: string) => {
const trimmed = value.trim();
if (!trimmed || loading || !client || !config) return;
setInputText('');
setInputKey((prev) => prev + 1);
setLoading(true);
setError(null);
const userMessage: Message = { role: 'user', content: trimmed };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
try {
const stream = await client.chat.completions.create({
model: config.model,
messages: updatedMessages,
stream: true,
});
const assistantMessage: Message = { role: 'assistant', content: '' };
setMessages((prev) => [...prev, assistantMessage]);
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content || '';
assistantMessage.content += delta;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = { ...assistantMessage };
return updated;
});
}
} catch (err: any) {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: `Error: ${err.message}` },
]);
} finally {
setLoading(false);
}
}, [loading, client, config, messages]);
useInput((_input, key) => {
if (key.ctrl && _input === 'c') exit();
});
const visibleMessages = messages.filter((msg) => msg.role !== 'system');
const providerInfo = config ? getProvider(config.provider) : null;
return (
<Box flexDirection="column" height="100%">
<BigText text="ProtoAgent" font="tiny" colors={["#09A469"]} />
{config && (
<Text dimColor>Model: {providerInfo?.name || config.provider} / {config.model}</Text>
)}
{error && <Text color="red">{error}</Text>}
{!initialized && !error && !needsSetup && <Text>Initializing...</Text>}
{needsSetup && (
<InlineSetup onComplete={initializeWithConfig} />
)}
<Box flexDirection="column" flexGrow={1}>
{visibleMessages.map((msg, i) => (
<Box key={i} flexDirection="column">
{msg.role === 'user' ? (
<Text>
<Text color="green" bold>{'> '}</Text>
<Text>{msg.content}</Text>
</Text>
) : (
<Text>{msg.content}</Text>
)}
</Box>
))}
{loading && visibleMessages[visibleMessages.length - 1]?.role === 'user' && (
<Text dimColor>Agent is thinking...</Text>
)}
</Box>
{initialized && (
<Box borderStyle="round" borderColor="green" paddingX={1}>
<Text color="green" bold>{'> '}</Text>
<TextInput
key={inputKey}
defaultValue={inputText}
onChange={setInputText}
placeholder="Type your message..."
onSubmit={handleSubmit}
/>
</Box>
)}
</Box>
);
};Verification
Build and run the configure wizard:
npm install
npm run build
node dist/cli.js configureYou should see a provider/model selector, then an API key prompt. After completing setup, run:
npm run devThe app should show your configured model name and stream responses. Your credentials are now stored in your protoagent.jsonc file. In a later part, we'll make it possible for the protoagent.jsonc to load your API keys from environment variables instead of having it in the file for a more secure setup.
Snapshot
Your project should match protoagent-build-your-own-checkpoints/part-3.
What comes next
Part 4 introduces the agentic loop — the tool-use cycle where the model can call tools and the app executes them. This is where ProtoAgent stops being a chat wrapper and becomes an agent.