Write an AI provider
An AI provider in arka-deck consists of:
- A manifest (static metadata: label, models, auth type)
- An instance (user configuration: API key, model, baseUrl, default) persisted under
<arkaHome>/providers/providers.db - A
ProviderTester(connection check: quick check or live probe) - A
ChatRuntime(agentic chat stream) - Optionally an
LlmInvokerfor 1-shot workers
API keys are encrypted AES-256-GCM on the server side, never exposed to the UI or logged.
Step 1 — Create the addon structure
Section titled “Step 1 — Create the addon structure”addons/<provider-name>/ manifest.json ← kind: provider, status: draft
src/ index.ts ← exports tester + runtime tester.ts ← ProviderTester impl tester.test.ts runtime.ts ← ChatRuntime impl specs/SPEC-../ plans/PLAN-../Step 2 — Implement the tester
Section titled “Step 2 — Implement the tester”// addons/<name>/src/tester.ts
import type { ProviderTester, ProviderTesterInput, TestResult } from '../../../core/ports/outbound/provider-tester.js';
export class MyProviderTester implements ProviderTester { async test(input: ProviderTesterInput): Promise<TestResult> { if (input.kind === 'check') return this.runCheck(input); return this.runLive(input); }
private async runCheck(input: ProviderTesterInput): Promise<TestResult> { if (!input.apiKey) return { ok: false, error: 'API key required' }; return { ok: true }; }
private async runLive(input: ProviderTesterInput): Promise<TestResult> { try { const response = await fetch(/* provider endpoint */, { method: 'POST', headers: { 'Authorization': `Bearer ${input.apiKey}` }, body: JSON.stringify({ /* minimal probe */ }), signal: AbortSignal.timeout(30_000), }); if (!response.ok) return { ok: false, error: `${response.status}: ${response.statusText}` }; return { ok: true, latencyMs: /* ... */ }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } }}Guarantees to respect:
- No API key logging.
- Strict timeout (30s default).
- Error truncated to ~200 characters.
- No stack trace in the returned
error.
Step 3 — Wire into the composition root
Section titled “Step 3 — Wire into the composition root”import { MyProviderTester } from '../../addons/<name>/src/tester.js';
testers.set('<manifest-id>', new MyProviderTester());Step 4 — Add the manifest
Section titled “Step 4 — Add the manifest”PROVIDER_MANIFESTS.push({ id: '<manifest-id>', label: 'My Provider', description: 'Short description.', addonId: '<provider-name>', models: [ { id: 'model-fast', label: 'Fast model' }, { id: 'model-pro', label: 'Pro model' }, ], authKind: 'apikey', // or 'oauth' / 'none' apiKeyHint: 'https://docs.provider.com/api-keys', capabilities: { streaming: true, vision: false, functions: true },});Step 5 — Implement the ChatRuntime
Section titled “Step 5 — Implement the ChatRuntime”To allow chat (not just connection test):
// addons/<name>/src/runtime.ts
import type { ChatRuntime, StreamMessageInput, ChatStreamEvent } from '../../../core/ports/outbound/chat-runtime.js';
export class MyProviderRuntime implements ChatRuntime { async *streamMessage(input: StreamMessageInput): AsyncIterable<ChatStreamEvent> { // Map provider SSE → ChatStreamEvent (block-start / delta / block-end / done / error) }}Wire into composition/addons/chat-runtime-registry.ts.
Step 6 — Tests
Section titled “Step 6 — Tests”import { vi, describe, it, expect, beforeEach } from 'vitest';import { MyProviderTester } from './tester.js';
describe('MyProviderTester', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); });
it('check refuses an empty key', async () => { const tester = new MyProviderTester(); const result = await tester.test({ modelId: 'model-fast', kind: 'check' }); expect(result.ok).toBe(false); expect(result.error).toContain('API key required'); });
it('live probe — success', async () => { (fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ /* payload */ }), }); // ... });});Cases to cover: check OK, empty key, 401, timeout, empty response, network error.
Security
Section titled “Security”| Guarantee | Mechanism |
|---|---|
| Key never plain-text on disk | AES-256-GCM in SQLite |
| Master key protected | <arkaHome>/secrets/secrets.key, mode 600 |
| Key never returned to UI | Public DTO exposes only apiKeyConfigured: boolean |
| No key logging | The tester receives the decrypted key in ephemeral memory |
| Controlled external transmission | Only the live test POSTs the key to the provider |
| Runtime URL | Loopback + *.arkalabs.app HTTPS by default, external host refused (ADR 0006) |
Anti-patterns to avoid
Section titled “Anti-patterns to avoid”Storing the key plain-text in the UI
Section titled “Storing the key plain-text in the UI”// ❌ NEVERlocalStorage.setItem('provider-key', apiKey);
// ✅ Always send to the server, which encrypts + storesawait providersApi.createInstance({ name, manifestId, modelId, apiKey });Logging a full instance
Section titled “Logging a full instance”// ❌ may leak the keylogger.info('instance created', instance);
// ✅logger.info('instance created', { id: instance.id, manifestId: instance.manifestId });Hardcoding a model in the code
Section titled “Hardcoding a model in the code”// ❌const model = 'gemini-2.5-flash';
// ✅const inst = await forProviderInstances.getById(instanceId);const model = inst.modelId; // user-configurable via the UIQuick reference
Section titled “Quick reference”| Action | File |
|---|---|
| Instance domain | core/domain/providers/provider-instance.ts |
| AES-GCM cipher | adapters/outbound/secrets/fs-secret-cipher.ts |
| CRUD use-cases | core/use-cases/providers/build-for-provider-instances.ts |
| Server routes | adapters/inbound/web/server/src/routes/providers.ts |
| Catalogue manifests | composition/addons/provider-manifests.ts |
| Tester registry | composition/addons/providers-tester-registry.ts |
| Runtime registry (chat) | composition/addons/chat-runtime-registry.ts |