Typed Proxies
Last updated April 7, 2026
The three typed proxies in @kb-labs/platform-client: LLM, Cache, Vector Store.
@kb-labs/platform-client ships three typed proxies for the core adapter services: llm, cache, and vectorStore. (The fourth, telemetry, is covered separately — see Telemetry — because it has a different batching model.)
All three are thin wrappers over the generic platform.call(adapter, method, ...args) escape hatch. The wrappers give you typed method signatures for the common paths; if you need something the wrappers don't cover, drop down to call().
The pattern
Every proxy method is just:
async method(...args): Promise<T> {
return this.call<T>('<adapter>', '<method>', ...args);
}call is a function bound to the KBPlatform instance that dispatches to POST /platform/v1/{adapter}/{method} with { args: [...] } as the body. The server unpacks the args, calls the real adapter method, and returns the result.
This means:
- Every proxy method is one HTTP round-trip.
- Typed args/returns but no runtime validation — the client trusts the server to honor the contract.
- No hidden state. No caching, no retry, no queueing.
platform.llm — LLMProxy
class LLMProxy {
async complete(prompt: string, options?: LLMOptions): Promise<LLMResponse>;
async chatWithTools(messages: unknown[], options: unknown): Promise<LLMToolCallResponse>;
}complete
Single-shot text completion.
const response = await platform.llm.complete('Write a haiku about cats', {
temperature: 0.7,
maxTokens: 100,
systemPrompt: 'You are a poet.',
});
console.log(response.content);
// "Silent paws on floor / Golden eyes watching the moon / Soft fur in moonlight"
console.log(response.usage.promptTokens); // number
console.log(response.usage.completionTokens); // number
console.log(response.model); // string (e.g. "gpt-4o-mini")LLMOptions
interface LLMOptions {
model?: string; // override the default model
temperature?: number; // 0-2
maxTokens?: number;
stop?: string[];
systemPrompt?: string;
}These are the only fields the proxy surfaces. The server-side ILLM interface has more (execution policy, cache/stream modes, metadata) but they're not exposed in the client — they're platform-internal concerns.
LLMResponse
interface LLMResponse {
content: string;
usage: {
promptTokens: number;
completionTokens: number;
};
model: string;
}Simpler than the server-side response (no cacheReadTokens, no providerUsage, no execution metadata). If you need the full response, use platform.call<ServerLLMResponse>('llm', 'complete', prompt, options) with your own type.
chatWithTools
Native tool-call support. The proxy accepts untyped messages and options because the full type for LLMToolCallOptions lives in @kb-labs/core-platform and pulling it into a zero-dep client would add weight.
const response = await platform.llm.chatWithTools(
[
{ role: 'user', content: 'What\'s the weather in SF?' },
],
{
tools: [
{
name: 'get_weather',
description: 'Get current weather for a city',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
],
toolChoice: 'auto',
},
);
// Either a text response or a tool-call request:
if (response.toolCalls) {
for (const call of response.toolCalls) {
console.log(`Tool: ${call.name}, input:`, call.input);
}
} else {
console.log(response.content);
}The types are loose on purpose — the client doesn't validate the tool schemas. The server does. If you need strict typing in your app, define your own TypeScript types for the messages and options.
What's NOT in LLMProxy
stream()— the client doesn't expose streaming. LLM streaming over HTTP requires SSE or WebSocket, neither of which the client speaks. For streaming, use the raw fetch API againstPOST /platform/v1/llm/streamdirectly (if the server exposes it) or keep the streaming stage inside the platform and return the final text from a workflow.getProtocolCapabilities()— not proxied. The client assumes standard HTTP request/response.
platform.cache — CacheProxy
class CacheProxy {
async get<T = unknown>(key: string): Promise<T | null>;
async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
async delete(key: string): Promise<void>;
async clear(pattern?: string): Promise<void>;
}Four methods, covering the core KV operations.
get and set
interface UserProfile {
name: string;
email: string;
}
// Store with a 1-hour TTL
await platform.cache.set<UserProfile>('user:123', {
name: 'Alice',
email: 'alice@example.com',
}, 3_600_000);
// Retrieve; returns null if missing or expired
const profile = await platform.cache.get<UserProfile>('user:123');
if (profile) {
console.log(`Hello, ${profile.name}`);
}Important:
getreturnsT | null. NotT | undefined. Check explicitly.ttlis in milliseconds.60_000= 60 seconds.- The generic is advisory. The client doesn't validate that the returned value matches
T. The server decides what to return; you cast via the generic.
delete
await platform.cache.delete('user:123');Idempotent. Deleting a missing key is not an error.
clear
// Clear all keys (dangerous — clears the whole cache):
await platform.cache.clear();
// Clear by pattern:
await platform.cache.clear('user:*');The pattern uses glob-style matching on the server side. Exact syntax depends on the configured cache adapter.
What's NOT in CacheProxy
The server-side ICache interface has 10 methods including sorted sets (zadd, zrangebyscore, zrem) and atomic operations (setIfNotExists). The client only wraps the 4 basic ones.
For the others, use platform.call():
// Sorted sets
await platform.call<void>('cache', 'zadd', 'queue:jobs', Date.now(), 'job-42');
const due = await platform.call<string[]>('cache', 'zrangebyscore', 'queue:jobs', 0, Date.now());
// Atomic setIfNotExists
const acquired = await platform.call<boolean>('cache', 'setIfNotExists', 'lock:deploy', { pid: process.pid }, 30_000);platform.vectorStore — VectorStoreProxy
class VectorStoreProxy {
async search(query: Record<string, unknown>): Promise<unknown[]>;
async upsert(documents: unknown[]): Promise<void>;
async delete(ids: string[]): Promise<void>;
async count(): Promise<number>;
}search
Vector similarity search. The query shape depends on your vector store adapter — the proxy accepts a generic Record<string, unknown> because Qdrant, Pinecone, and Weaviate all use slightly different query formats.
// Typical Qdrant-style query:
const results = await platform.vectorStore.search({
vector: [0.123, 0.456, /* ... */ 0.789],
limit: 10,
filter: { must: [{ key: 'category', match: { value: 'code' } }] },
});
for (const hit of results) {
console.log(hit);
}The return is an untyped unknown[]. Cast to whatever your adapter returns:
interface SearchHit {
id: string;
score: number;
payload?: Record<string, unknown>;
}
const results = await platform.vectorStore.search({ ... }) as SearchHit[];upsert
await platform.vectorStore.upsert([
{
id: 'doc-1',
vector: [0.1, 0.2, /* ... */],
payload: { source: 'file.ts', line: 42 },
},
{
id: 'doc-2',
vector: [/* ... */],
payload: { source: 'other.ts', line: 10 },
},
]);Like search, the document shape is adapter-specific. The client doesn't validate.
delete and count
await platform.vectorStore.delete(['doc-1', 'doc-2']);
const total = await platform.vectorStore.count();
console.log(`${total} documents in the index`);What's NOT in VectorStoreProxy
- Typed query DSL. The server-side interface has specific types for
VectorQueryOptions, filters, etc. The client treats queries as opaque objects. get(id)for fetching a document by ID. Useplatform.call('vectorStore', 'get', id).- Batch operations beyond
upsertanddelete. Use the genericcall().
Calling plugin commands
The typed proxies don't include a commands proxy — plugin commands run through the CLI or REST host, not through the Unified Platform API directly. To invoke a plugin command from a product client, use the generic call() against whatever endpoint the gateway exposes (the exact URL depends on your gateway config):
// If the gateway exposes a commands runner adapter:
const result = await platform.call<CommandResult>('commands', 'run', {
commandId: 'commit:commit',
flags: { dryRun: true },
});Or call the REST route the plugin declares directly via raw fetch — the plugin's manifest declares routes like POST /api/v1/plugins/commit/generate, which you can hit without the platform-client:
const response = await fetch(`${endpoint}/api/v1/plugins/commit/generate`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scope: 'packages/core' }),
});For workflow triggering specifically, see Workflows.
Error handling
Every proxy method throws on failure. The error message comes from the server's PlatformCallResponse.error:
try {
const result = await platform.llm.complete('hello');
} catch (err) {
if (err instanceof Error) {
console.error('LLM call failed:', err.message);
}
}HTTP errors (401, 429, 500) also throw with a message containing the status. For retry strategies and error classification, see Error handling.
What to read next
- Workflows — triggering workflows via the generic
call(). - Telemetry — the fourth proxy, with its batching model.
- Error handling — how to handle failures.
- Overview — the package surface and the Unified Platform API shape.