KB LabsDocs

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:

TypeScript
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.llmLLMProxy

TypeScript
class LLMProxy {
  async complete(prompt: string, options?: LLMOptions): Promise<LLMResponse>;
  async chatWithTools(messages: unknown[], options: unknown): Promise<LLMToolCallResponse>;
}

complete

Single-shot text completion.

TypeScript
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

TypeScript
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

TypeScript
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.

TypeScript
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 against POST /platform/v1/llm/stream directly (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.cacheCacheProxy

TypeScript
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

TypeScript
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:

  • get returns T | null. Not T | undefined. Check explicitly.
  • ttl is 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

TypeScript
await platform.cache.delete('user:123');

Idempotent. Deleting a missing key is not an error.

clear

TypeScript
// 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():

TypeScript
// 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.vectorStoreVectorStoreProxy

TypeScript
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.

TypeScript
// 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:

TypeScript
interface SearchHit {
  id: string;
  score: number;
  payload?: Record<string, unknown>;
}
 
const results = await platform.vectorStore.search({ ... }) as SearchHit[];

upsert

TypeScript
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

TypeScript
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. Use platform.call('vectorStore', 'get', id).
  • Batch operations beyond upsert and delete. Use the generic call().

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):

TypeScript
// 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:

TypeScript
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:

TypeScript
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.

  • 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.