Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Philosophy

BotMaker packages OpenClaw into a turnkey bot platform. We are responsible
for producing **useful, usable, and working configurations** for all users.
"OpenClaw-side issue" is not an excuse — if the generated config is broken,
that's our bug. Every bot created through the wizard must work out of the box.

Users download this project from GitHub/GHCR. They may run:
- 100% cloud APIs (Anthropic, OpenAI, Google, etc.) with zero Ollama
- 100% local Ollama with zero cloud APIs
- A mix of both

The UI wizard, template generation, and proxy configuration must produce
valid, working configs for **every** combination.

## Build & Dev Commands

### Backend (root)
```bash
npm run build # Compile TypeScript
npm run dev # Hot-reload dev server (tsx watch)
npm run start # Run compiled server
npm run test # Vitest unit tests
npm run lint # ESLint
npx vitest run src/bots/store.test.ts # Single test file
```

### Dashboard (`dashboard/`)
```bash
npm run dev # Vite dev server (localhost:5173)
npm run build # TypeScript check + Vite build
npm run test # Vitest + React Testing Library
```

### Proxy (`proxy/`)
```bash
npm run dev # Hot-reload (tsx watch)
npm run build # TypeScript compile
npm run test # Vitest
```

### Full build
```bash
npm run build:all # Backend + dashboard
```

### Docker
```bash
docker compose up -d # Start botmaker + keyring-proxy
docker compose --profile build build botenv # Build bot environment image
```

Test framework is **Vitest** (not Jest) across all three modules. Tests use `*.test.ts` pattern.

## Architecture

Three independent TypeScript services sharing one repo:

```
src/ → Backend: Fastify API (port 7100), bot lifecycle, Docker orchestration
proxy/src/ → Keyring-proxy: credential vault (admin:9100, data:9101), request forwarding
dashboard/src/ → React + Vite frontend: wizard, dashboard, secrets management
```

All three have their own `package.json`, `tsconfig.json`, and `vitest.config.ts`.

### Request Flow
```
User → Dashboard UI → Backend API (POST /api/bots)
Creates: openclaw.json (templates.ts)
Creates: Docker container (DockerService.ts)
Registers: bot with keyring-proxy (proxy/client.ts)
Bot container → keyring-proxy:9101/v1/{provider}/... → upstream API
(injects real API key at network edge)
```

### Zero-Trust Credential Model
Bots never hold real API keys. They get a proxy token that keyring-proxy
validates, then the proxy injects the real API key when forwarding upstream.
Provider names get a `-proxy` suffix (e.g., `openai-proxy`) to avoid
collisions with OpenClaw's built-in provider defaults.

### Key Files
- `src/server.ts` — All API routes (bot CRUD, auth, stats, admin)
- `src/bots/templates.ts` — Generates openclaw.json from wizard input
- `src/bots/store.ts` — Bot DB CRUD (better-sqlite3, no ORM)
- `src/services/DockerService.ts` — Container lifecycle
- `proxy/src/types.ts` — All 15+ LLM vendor configs + `initOllamaVendor()`
- `proxy/src/services/upstream.ts` — Transparent request forwarding (any path)
- `proxy/src/routes/proxy.ts` — Auth validation, key selection, forwarding
- `dashboard/src/wizard/` — Multi-step bot creation wizard
- `dashboard/src/config/providers/` — Provider definitions (22 providers)

### Database
Direct SQL via better-sqlite3 (no ORM). Two separate SQLite databases:
- Backend: `${DATA_DIR}/botmaker.db` — `bots` table
- Proxy: `${DB_PATH}/proxy.db` — `provider_keys`, `bots`, `usage_logs` (AES-256 encrypted keys)

### Provider API Type Mapping
`templates.ts:getApiTypeForProvider()` maps provider IDs to OpenClaw API types:
- `anthropic` → `anthropic-messages`
- `google` → `google-gemini`
- `openai` → `openai-responses`
- Everything else (ollama, groq, deepseek, mistral, etc.) → `openai-completions`

### Ollama Integration
- Optional: enabled by `OLLAMA_UPSTREAM` env var on keyring-proxy
- Uses `noAuth: true` + `forceNonStreaming: true` (OpenClaw can't parse streaming tool-call deltas)
- Bots address it identically to cloud: `http://keyring-proxy:9101/v1/ollama`
- Context window set via `OLLAMA_CONTEXT_LENGTH` env var on Ollama container

## Known Issue: memorySearch

OpenClaw's memorySearch auto-discovery looks for providers named exactly
`"openai"` or `"gemini"`. Our `-proxy` suffix means auto-discovery always
fails. The template generator must explicitly produce a `memorySearch`
section in openclaw.json pointing at the correct keyring-proxy embedding
endpoint, or explicitly disable it for providers without embedding support.

~13/22 providers support OpenAI-compatible `/embeddings`; the rest
(anthropic, groq, cerebras, perplexity, moonshot) need `enabled: false`.

## CI

GitHub Actions (`.github/workflows/ci.yml`): Node 20+22 matrix, lints and
tests all three modules, builds Docker image, pushes to GHCR on main.
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ services:
- MASTER_KEY_FILE=/secrets/master_key
- ADMIN_TOKEN_FILE=/secrets/admin_token
# Optional: enables Ollama vendor (local LLM support)
# - OLLAMA_UPSTREAM=http://host.docker.internal:11434
- OLLAMA_UPSTREAM=http://host.docker.internal:11434
Comment thread
jgarzik marked this conversation as resolved.
volumes:
- proxy-data:/data
- ./secrets:/secrets:ro
Expand Down
50 changes: 48 additions & 2 deletions src/bots/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { createBotWorkspace, getBotWorkspacePath, deleteBotWorkspace, getApiTypeForProvider, type BotWorkspaceConfig } from './templates.js';
import { createBotWorkspace, getBotWorkspacePath, deleteBotWorkspace, getApiTypeForProvider, getMemorySearchConfig, type BotWorkspaceConfig } from './templates.js';

describe('templates', () => {
let testDir: string;
Expand Down Expand Up @@ -60,6 +60,37 @@ describe('templates', () => {
});
});

describe('getMemorySearchConfig', () => {
const proxy = { baseUrl: 'http://proxy:9101/v1/openai', token: 'tok-123' };

it.each([
['openai', 'text-embedding-3-small'],
['mistral', 'mistral-embed'],
['ollama', 'nomic-embed-text'],
['deepseek', 'text-embedding-3-small'],
])('%s with proxy returns embedding model %s', (provider, model) => {
expect(getMemorySearchConfig(provider, proxy)).toEqual({
provider: 'openai',
model,
remote: { baseUrl: proxy.baseUrl, apiKey: proxy.token },
});
});

it.each([
'anthropic', 'google', 'groq', 'cerebras', 'perplexity', 'moonshot', 'ovhcloud',
])('%s returns disabled', (provider) => {
expect(getMemorySearchConfig(provider, proxy)).toEqual({ enabled: false });
});

it('unknown provider returns disabled', () => {
expect(getMemorySearchConfig('nonexistent', proxy)).toEqual({ enabled: false });
});

it('no proxy returns disabled even for supported provider', () => {
expect(getMemorySearchConfig('openai')).toEqual({ enabled: false });
});
});

describe('getBotWorkspacePath', () => {
it('should return correct path', () => {
const result = getBotWorkspacePath('/data', 'my-bot');
Expand Down Expand Up @@ -111,7 +142,9 @@ describe('templates', () => {
controlUi: { allowInsecureAuth: true },
});
expect((openclawConfig.channels as Record<string, unknown>).telegram).toEqual({ enabled: true });
expect(((openclawConfig.agents as Record<string, unknown>).defaults as Record<string, unknown>).model).toEqual({ primary: 'openai/gpt-4' });
const defaults = (openclawConfig.agents as Record<string, unknown>).defaults as Record<string, unknown>;
expect(defaults.model).toEqual({ primary: 'openai/gpt-4' });
expect(defaults.memorySearch).toEqual({ enabled: false });
expect(openclawConfig.models).toBeUndefined();
});

Expand All @@ -137,6 +170,16 @@ describe('templates', () => {
},
},
});

const defaults = (openclawConfig.agents as Record<string, unknown>).defaults as Record<string, unknown>;
expect(defaults.memorySearch).toEqual({
provider: 'openai',
model: 'text-embedding-3-small',
remote: {
baseUrl: 'http://proxy:9101/v1',
apiKey: 'proxy-token-123',
},
});
});

it('should create sessions and sandbox directories', () => {
Expand Down Expand Up @@ -202,6 +245,9 @@ describe('templates', () => {
},
},
});

const defaults = (openclawConfig.agents as Record<string, unknown>).defaults as Record<string, unknown>;
expect(defaults.memorySearch).toEqual({ enabled: false });
});

it('should use openai-completions API type for venice with proxy', () => {
Expand Down
63 changes: 63 additions & 0 deletions src/bots/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,68 @@ export function getApiTypeForProvider(provider: string): string {
}
}

/**
* Map provider to default embedding model.
* null = provider has no OpenAI-compatible /embeddings endpoint.
*/
const OPENAI_EMBED = 'text-embedding-3-small';
const NOMIC_EMBED = 'nomic-embed-text';

export const EMBEDDING_MODELS: Record<string, string | null> = {
openai: OPENAI_EMBED,
mistral: 'mistral-embed',
deepseek: OPENAI_EMBED,
ollama: NOMIC_EMBED,
fireworks: NOMIC_EMBED,
togetherai: 'togethercomputer/m2-bert-80M-8k-retrieval',
deepinfra: 'BAAI/bge-large-en-v1.5',
nvidia: 'NV-Embed-QA',
grok: OPENAI_EMBED,
nebius: OPENAI_EMBED,
scaleway: OPENAI_EMBED,
huggingface: 'sentence-transformers/all-MiniLM-L6-v2',
minimax: 'embo-01',
venice: OPENAI_EMBED,
openrouter: `openai/${OPENAI_EMBED}`,
// No embedding support
anthropic: null,
google: null,
groq: null,
cerebras: null,
perplexity: null,
moonshot: null,
ovhcloud: null,
};
Comment thread
jgarzik marked this conversation as resolved.

export type MemorySearchConfig =
| { enabled: false }
| { provider: 'openai'; model: string; remote: { baseUrl: string; apiKey: string } };

/**
* Build memorySearch config for openclaw.json.
* Returns embedding endpoint config for providers with /embeddings support,
* or { enabled: false } for providers without it.
*/
export function getMemorySearchConfig(
provider: string,
proxy?: ProxyConfig,
): MemorySearchConfig {
const embeddingModel = EMBEDDING_MODELS[provider];

if (!embeddingModel || !proxy) {
return { enabled: false };
}

return {
provider: 'openai',
model: embeddingModel,
remote: {
baseUrl: proxy.baseUrl,
apiKey: proxy.token,
},
};
}
Comment thread
jgarzik marked this conversation as resolved.

/**
* Generate openclaw.json configuration.
* Follows OpenClaw's expected config structure for gateway mode.
Expand Down Expand Up @@ -142,6 +204,7 @@ function generateOpenclawConfig(config: BotWorkspaceConfig): object {
primary: modelSpec,
},
workspace: '/app/botdata/workspace',
memorySearch: getMemorySearchConfig(config.aiProvider, config.proxy),
},
},
...(modelsConfig && { models: modelsConfig }),
Expand Down
Loading