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
5 changes: 4 additions & 1 deletion Dockerfile.botenv
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Build: docker compose build botenv
# Usage: Automatically used by botmaker when spawning bot containers

ARG BASE_IMAGE=openclaw:latest
ARG BASE_IMAGE=ghcr.io/openclaw/openclaw:latest
FROM ${BASE_IMAGE}

# Switch to root for package installation
Expand All @@ -29,5 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
iproute2 netcat-openbsd dnsutils \
&& rm -rf /var/lib/apt/lists/*

# Make openclaw CLI available on PATH
RUN ln -s /app/openclaw.mjs /usr/local/bin/openclaw

# Switch back to non-root user
USER node
35 changes: 28 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Traditional setups pass API keys directly to bots—if a bot is compromised, you

### Additional Features

- **Multi-AI Provider Support** - OpenAI, Anthropic, Google Gemini, Venice
- **Multi-AI Provider Support** - OpenAI, Anthropic, Google Gemini, Venice, Ollama (local LLMs)
- **Multi-Channel Wizard** - Telegram, Discord (all others supported by chatting with your bot post-setup)
- **Container Isolation** - Each bot runs in its own Docker container
- **Dashboard** - Creation wizard, monitoring, diagnostics
Expand Down Expand Up @@ -60,12 +60,9 @@ Traditional setups pass API keys directly to bots—if a bot is compromised, you

- Docker and Docker Compose
- Node.js 20+ (for development only)
- OpenClaw base image — build from [OpenClaw repo](https://github.com/jgarzik/openclaw) or use a prebuilt image:
- OpenClaw base image — pulled automatically from GHCR, or pull manually:
```bash
# Option A: Build from source
git clone https://github.com/jgarzik/openclaw && cd openclaw && docker build -t openclaw:latest .

# Option B: Use a prebuilt image (set OPENCLAW_BASE_IMAGE in docker-compose.yml)
docker pull ghcr.io/openclaw/openclaw:latest
```

## Quick Start
Expand Down Expand Up @@ -156,6 +153,30 @@ On first visit, you'll see a login form. Enter the password to access the dashbo

3. **Monitor** — The Dashboard tab shows all bots with their status. Start/stop bots, view logs, and check resource usage.

### Ollama (Local LLM) Support

BotMaker can use [Ollama](https://ollama.com/) for local LLM inference. The Ollama connection is configured on the proxy side — bots never see the Ollama URI, maintaining the zero-trust architecture.

**Setup:**

1. Install and run Ollama on the host machine
2. Pull a model: `ollama pull qwen2.5:32b-instruct`
3. Add `OLLAMA_UPSTREAM` to the keyring-proxy environment in `docker-compose.yml`:
```yaml
keyring-proxy:
environment:
- OLLAMA_UPSTREAM=http://host.docker.internal:11434
```
4. Restart: `docker compose up -d`
5. In the dashboard wizard, select "Ollama" as the provider and pick a model
6. Set `OLLAMA_CONTEXT_LENGTH=32768` (or higher) in your Ollama environment for tool-use models

**Notes:**
- `host.docker.internal` resolves to the host machine from inside Docker
- If Ollama runs on a different machine, replace with its IP/hostname
- No API key is needed — Ollama requests are proxied without authentication
- Streaming is automatically handled by the proxy for tool-call compatibility

### Login API

```bash
Expand All @@ -182,7 +203,7 @@ curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:7100/api/logout
| `DATA_DIR` | ./data | Database and bot workspaces |
| `SECRETS_DIR` | ./secrets | Per-bot secret storage |
| `BOTENV_IMAGE` | botmaker-env:latest | Bot container image (built from botenv) |
| `OPENCLAW_BASE_IMAGE` | openclaw:latest | Base image for botenv |
| `OPENCLAW_BASE_IMAGE` | ghcr.io/openclaw/openclaw:latest | Base image for botenv |
| `BOT_PORT_START` | 19000 | Starting port for bot containers |
| `SESSION_EXPIRY_MS` | 86400000 | Session expiry in milliseconds (default 24h) |

Expand Down
13 changes: 13 additions & 0 deletions dashboard/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,16 @@ export async function fetchProxyHealth(): Promise<ProxyHealthResponse> {
});
return handleResponse<ProxyHealthResponse>(response);
}

export async function fetchDynamicModels(baseUrl: string, apiKey?: string): Promise<string[]> {
let url = `${API_BASE}/models/discover?baseUrl=${encodeURIComponent(baseUrl)}`;
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
}
const response = await fetch(url, { headers: getAuthHeaders() });
Comment on lines +183 to +187
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchDynamicModels() sends apiKey in the query string. Even though the request is authenticated, URLs are frequently captured in logs/telemetry/history, which can leak provider credentials. Prefer sending the key in a header or POST body and keep secrets out of the URL.

Suggested change
let url = `${API_BASE}/models/discover?baseUrl=${encodeURIComponent(baseUrl)}`;
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
}
const response = await fetch(url, { headers: getAuthHeaders() });
const url = `${API_BASE}/models/discover?baseUrl=${encodeURIComponent(baseUrl)}`;
const headers = getAuthHeaders();
if (apiKey) {
(headers as Record<string, string>)['X-Api-Key'] = apiKey;
}
const response = await fetch(url, { headers });

Copilot uses AI. Check for mistakes.
const data = await handleResponse<{ models: string[] }>(response);
return data.models;
}

/** @deprecated Use fetchDynamicModels instead */
export const fetchOllamaModels = fetchDynamicModels;
3 changes: 2 additions & 1 deletion dashboard/src/config/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { anthropic } from './anthropic';
import { google } from './google';
import { venice } from './venice';
import { openrouter } from './openrouter';
import { ollama } from './ollama';

export type { ProviderConfig, ModelInfo };

export const PROVIDERS: ProviderConfig[] = [openai, anthropic, google, venice, openrouter];
export const PROVIDERS: ProviderConfig[] = [openai, anthropic, google, venice, openrouter, ollama];

export const AI_PROVIDERS = PROVIDERS.map((p) => ({
value: p.id,
Expand Down
11 changes: 11 additions & 0 deletions dashboard/src/config/providers/ollama.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ProviderConfig } from './types';

export const ollama: ProviderConfig = {
id: 'ollama',
label: 'Ollama',
baseUrl: 'http://localhost:11434/v1',
defaultModel: '',
models: [],
dynamicModels: true,
noAuth: true,
};
3 changes: 3 additions & 0 deletions dashboard/src/config/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ export interface ProviderConfig {
models: ModelInfo[];
defaultModel: string;
keyHint?: string; // Placeholder hint for API key format (e.g., "sk-ant-...")
dynamicModels?: boolean; // Models should be fetched at runtime (e.g., Ollama)
baseUrlEditable?: boolean; // Show editable base URL field in wizard
noAuth?: boolean; // Provider requires no API key (e.g., local Ollama)
}
6 changes: 6 additions & 0 deletions dashboard/src/dashboard/BotCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export function BotCard({ bot, onStart, onStop, onDelete, loading }: BotCardProp
<span className="bot-card-detail-value">{bot.port}</span>
</div>
)}
{bot.image_version && (
<div className="bot-card-detail">
<span className="bot-card-detail-label">Image</span>
<span className="bot-card-detail-value">{bot.image_version}</span>
</div>
)}
</div>

{bot.port && (isRunning || isStarting) && (
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/hooks/useBots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('useBots', () => {
container_id: 'container-1',
port: 3001,
gateway_token: 'token-1',
image_version: 'ghcr.io/openclaw/openclaw:latest',
status: 'running' as const,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
Expand All @@ -38,6 +39,7 @@ describe('useBots', () => {
container_id: null,
port: null,
gateway_token: null,
image_version: null,
status: 'stopped' as const,
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
Expand Down Expand Up @@ -118,6 +120,7 @@ describe('useBots', () => {
container_id: null,
port: null,
gateway_token: null,
image_version: null,
status: 'created' as const,
created_at: '2024-01-03T00:00:00Z',
updated_at: '2024-01-03T00:00:00Z',
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Bot {
container_id: string | null;
port: number | null;
gateway_token: string | null;
image_version: string | null;
status: BotStatus;
created_at: string;
updated_at: string;
Expand Down Expand Up @@ -68,6 +69,7 @@ export interface WizardFeatures {
export interface ProviderConfigInput {
providerId: string;
model: string;
baseUrl?: string; // For direct providers — written to workspace config
}

export interface ChannelConfigInput {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/wizard/context/WizardContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type WizardAction =
| { type: 'TOGGLE_CHANNEL'; channelId: string }
| { type: 'SET_ROUTING_TAGS'; tags: string[] }
| { type: 'SET_FEATURE'; feature: keyof WizardState['features']; value: unknown }
| { type: 'SET_PROVIDER_CONFIG'; providerId: string; config: { model?: string } }
| { type: 'SET_PROVIDER_CONFIG'; providerId: string; config: { model?: string; baseUrl?: string } }
| { type: 'SET_CHANNEL_CONFIG'; channelId: string; config: { token: string } }
| { type: 'RESET' };

Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/wizard/context/wizardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface WizardState {
sandboxTimeout: number;
sessionScope: SessionScope;
};
providerConfigs: Record<string, { model?: string } | undefined>;
providerConfigs: Record<string, { model?: string; baseUrl?: string } | undefined>;
channelConfigs: Record<string, { token: string } | undefined>;
}

Expand Down Expand Up @@ -86,6 +86,7 @@ export function buildCreateBotInput(state: WizardState): CreateBotInput {
const providers = state.enabledProviders.map((providerId) => ({
providerId,
model: state.providerConfigs[providerId]?.model ?? '',
baseUrl: state.providerConfigs[providerId]?.baseUrl,
}));

const channels = state.enabledChannels.map((channelType) => ({
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/wizard/pages/Page3Toggles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FeatureCheckbox } from '../components';
import type { SessionScope } from '../../types';
import './Page3Toggles.css';

const POPULAR_PROVIDERS = ['openai', 'anthropic', 'venice'];
const POPULAR_PROVIDERS = ['openai', 'anthropic', 'venice', 'ollama'];

export function Page3Toggles() {
const { state, dispatch } = useWizard();
Expand Down
21 changes: 21 additions & 0 deletions dashboard/src/wizard/pages/Page4Config.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,24 @@
.page4-empty p + p {
margin-top: var(--space-xs);
}

.page4-loading {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
}

.page4-refresh-btn {
margin-top: var(--space-xs);
padding: 4px 12px;
font-size: 12px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
}

.page4-refresh-btn:hover {
background: var(--bg-hover, #333);
}
Loading