Capabilities
@llm-ports/capabilities ships seven cognitive operation factories. Each factory takes config (port, schema, prompt fragments, hooks) at definition time and returns a typed function you call per-input.
The seven (extracted from BEPA's production stack):
| Factory | Returns | Default temperature | Use for |
|---|---|---|---|
createClassifier | typed object (Zod) | 0 | Pick one of N categories |
createScorer | typed object (Zod) | 0.1 | Rate against a rubric |
createExtractor | typed object (Zod) | 0 | Pull structured fields |
createSummarizer | text | 0.2 | Compress meaning-preserving |
createDrafter | text | 0.4 | Generate text in a persona |
createPlanner | typed object (Zod) | 0.2 | Decompose into steps |
createAnalyzer | typed object (Zod) | 0.3 | Evaluate / critique / compare |
10 more capabilities ship in v0.2: tag, detect, expand, rewrite, redact, respond, decide, answer, rerank, agent.
Why factories?
The factory pattern is the most important API choice in llm-ports. Three alternatives were considered and rejected:
- Plain helpers: force re-passing config (rubric, schema, hooks) on every call. Boilerplate at the call site.
- Decorators: require
experimentalDecoratorsconfig and pollute the type system. - Task descriptors: separate definition from execution, which is the wrong cut for LLM work where prompt fragments ARE the definition.
Factories let you bind rubric, schema, boundaryExamples, systemContext, and hooks once at definition time. Reading the call site shows what is bound and what is varying.
Common shape
Every factory follows the same template:
const fn = createX({
// Required
port: llm,
schema: z.object({...}), // for structured-output capabilities
schemaName: "operation-name", // appears in observability events
// Prompt fragments (string OR async function)
rubric: "...", // per-capability variant of the framework
examples: "...",
systemContext: async (input) => `dynamic ${await lookup()}`,
// Routing
taskType: "triage", // matches LLM_TASK_ROUTE_TRIAGE in env
priority: 2,
temperature: 0.2,
maxOutputTokens: 1024,
reasoningEffort: "high", // alpha.13+: per-factory, applies to every call
// Hooks (errors caught and logged but never re-thrown)
onBeforeCall: async (input) => { /* ... */ },
onResult: async (event) => { /* ... */ },
onError: async (err, input) => { /* ... */ },
});Then call with input:
const result = await fn({
content: "...",
contextOverride: "per-call extra context",
signal: controller.signal, // alpha.13+: per-call cancellation
forceProviderAlias: "expensive", // alpha.13+: per-call routing override
});reasoningEffort (per-factory, alpha.13+)
Set once at factory creation; forwarded to every call's underlying port.generateStructured / port.generateText. Use for capabilities that target a reasoning model (OpenAI o3 / o4-mini / gpt-5-nano / gpt-5, Groq's openai/gpt-oss-120b) where you want to escalate to "high" effort for quality, or drop to "low" for speed:
const score = createScorer({
port: llm,
schema: LeadScoreSchema,
schemaName: "lead-score",
rubric,
reasoningEffort: "high", // ← every score(...) call burns more CoT tokens
});Silently ignored by adapters whose providers don't honor the parameter (adapter-anthropic, adapter-google, adapter-ollama, adapter-vercel); the call still succeeds at the provider's default effort level.
signal and forceProviderAlias (per-call, alpha.13+)
Set per-invocation in the input arg. signal cancels the in-flight provider HTTP fetch (entry-only on Ollama; in-flight on the other 4 adapters). forceProviderAlias routes this single call directly to the named provider, bypassing the task-routing chain — useful for UIs where the operator picks a specific model:
const controller = new AbortController();
button.addEventListener("click", () => controller.abort());
const draft = await draftReply({
instructions: "...",
signal: controller.signal,
forceProviderAlias: "claude-opus", // bypass the cheap-fast chain for this one call
});Per-provider budget gates still apply on forceProviderAlias (so it can't be used to escape a hard cap); runtime fallback also does NOT engage — if the forced provider fails, the error propagates.
Hooks and the CapabilityEvent
Every successful call invokes onResult with a standardized CapabilityEvent:
interface CapabilityEvent<TOutput> {
capability: string; // "classify" | "score" | ... — the factory's identity
schemaName: string; // the user-supplied operation name
modelId: string; // which model actually ran
providerAlias: string; // which provider alias was selected
usage: { inputTokens, outputTokens, totalTokens };
cost: { inputUSD, outputUSD, totalUSD };
latencyMs: number;
output: TOutput; // the validated typed result
validationAttempts?: number; // present for structured-output capabilities
}Wire this to your analytics pipeline directly:
const classify = createClassifier({
port: llm,
schema: TriageSchema,
schemaName: "email-triage",
onResult: async (event) => {
await myDB.insert("llm_events", {
capability: event.capability,
schemaName: event.schemaName,
modelId: event.modelId,
cost: event.cost.totalUSD,
latencyMs: event.latencyMs,
timestamp: new Date(),
});
},
});The standard event shape across all capabilities means you can write one analytics handler and reuse it.
Dynamic prompt fragments
Prompt fragments accept either a string or a function returning a string. Functions can be sync or async, enabling DB lookups, feature flags, or context-derived content:
const classifyEmail = createClassifier({
port: llm,
schema: TriageSchema,
schemaName: "email-triage",
rubric: async () => await loadRubricFromConfigService(),
systemContext: async (input) => {
const sender = await getSenderProfile(input.content);
return `Sender warmth: ${sender.warmth}, last interaction: ${sender.lastSeen}`;
},
});Resolution happens lazily, per-input. No pre-compilation, no caching unless you add it yourself.
Hook safety
Hooks should not break the call. If your onResult handler throws, the capability:
- Catches the error
- Logs it via
console.warn - Returns the model's output to the caller anyway
This is intentional. Observability hooks are nice-to-have; production calls must succeed even when telemetry is broken.
If you want hard-failure on hook errors, throw in onError instead — that one re-throws.
Reading next
createClassifier→ — most-used capabilitycreateDrafter→ — text generation in a persona- Source code:
packages/capabilities/src/