Capabilities
The @pai/core capability declaration and binding API.
Package: @pai/core
Capabilities are named, typed requirements that tools and commands declare and createAgentRuntime binds to concrete implementations. This page is the type reference; Capabilities is the guide.
- Declaring on tools and consuming facades: Tools And Pending Actions.
- Aggregation on agents: Agent Definitions.
- The runtime binding slot and lifecycle hooks: Runtime.
defineCapability
function defineCapability<TFacade>(): <const TId extends string>(input: {
id: TId;
description?: string;
}) => Capability<TFacade, TId>;Declares a capability token. Curried so TFacade is explicit while TId is inferred as a literal:
export const sandbox = defineCapability<Sandbox>()({
id: "sandbox",
description: "Isolated compute environment with a shell and filesystem.",
});Use bare ids for first-party capabilities and prefixed ids ("acme.vault") for third parties.
Capability
type Capability<TFacade = unknown, TId extends string = string> = {
readonly kind: "capability";
/** Stable identity. Also the binding key in `createAgentRuntime({ capabilities })`. */
readonly id: TId;
readonly description?: string;
/** Phantom type witness for the facade operation bodies receive. */
readonly facade: TFacade;
};
type CapabilityFacadeOf<TCapability> =
TCapability extends Capability<infer TFacade, any> ? TFacade : never;A capability is a pure declaration: the token carries no behavior, and facade exists only at the type level. Concrete implementations arrive as CapabilityBinding values at createAgentRuntime.
type CapabilityMap = Record<string, Capability<any, any>>;
type CapabilityFacades<TCapabilities extends CapabilityMap> = {
readonly [TName in keyof TCapabilities]: CapabilityFacadeOf<
TCapabilities[TName]
>;
};CapabilityMap is the shape of an operation's capabilities declaration, keyed by the operation-local ctx name. CapabilityFacades is the shape of ctx.capabilities — the same keys, each mapped to its facade type.
CapabilityBinding
type CapabilityBinding<TFacade = unknown> = {
readonly kind: "capability-binding";
/** Token this binding implements. Its `id` must match the record key at the runtime. */
readonly capability: Capability<TFacade, string>;
/** Instance keying policy. */
readonly scope: CapabilityScope;
/** Produce a facade for one instance key. Runs once per operation per declared capability. */
acquire(input: CapabilityAcquireInput): TFacade | Promise<TFacade>;
/** Destroy one scope instance. Best-effort at runtime hooks; errors go to `onRuntimeError`. */
dispose?(input: CapabilityDisposeInput): Promise<void>;
/** Runtime shutdown: release local resources only — never durable state. */
close?(): Promise<void>;
};
type CapabilityAcquireInput = {
/** Opaque core-computed key. Compare or persist it; never parse it. */
key: string;
/** Present when acquisition is associated with a thread. */
threadId?: ThreadId;
scopeKey?: ScopeKey;
userKey?: UserKey;
signal: AbortSignal;
};
type CapabilityDisposeInput = {
/** Closed union; bindings must ignore unknown future reasons. */
reason: "thread-deleted";
/** Same opaque key supplied to acquire. */
key: string;
threadId?: ThreadId;
};acquire must be cheap: return a lazy facade and provision the underlying resource on first use. Two acquires for one key must resolve to the same underlying instance; an acquire after dispose(key) must provision fresh.
createCapabilityBinding
function createCapabilityBinding<TFacade>(
config: Omit<CapabilityBinding<TFacade>, "kind" | "scope"> & {
scope?: CapabilityScope;
},
): CapabilityBinding<TFacade>;Ergonomic constructor: applies the "thread" scope default and validates the binding shape at construction, throwing CapabilityBindingError for a missing token, empty id, missing acquire, or invalid scope.
export function sandboxCapability(options: SandboxCapabilityOptions) {
const sessions = createSandboxSessionManager(options);
return createCapabilityBinding<Sandbox>({
capability: sandbox,
acquire: ({ key }) => createSandbox(sessions.handle(key)),
dispose: async ({ key }) => {
await sessions.dispose(key);
},
close: () => sessions.close(),
});
}CapabilityScope
type CapabilityScope =
| "thread"
| "user"
| "scope"
| "global"
| ((input: CapabilityScopeInput) => string);
type CapabilityScopeInput = {
threadId: ThreadId;
scopeKey: ScopeKey;
userKey?: UserKey;
/** Typed app identity, same stance as `mapContext`: apps narrow it themselves. */
identity: unknown;
};Core derives an opaque instance key from the capability, scope kind, and the scope's identity fields; bindings map keys to resources:
| Scope | Built-in identity |
|---|---|
"thread" | capability + scope + thread |
"user" | capability + scope + user |
"scope" | capability + scope |
"global" | capability, shared across one provider keyspace |
| function | its return value, verbatim |
The built-in encoder is total for JavaScript strings, provider-safe, and injective across capability ids, scope kinds, and identity tuples. Treat the resulting key as opaque; custom scope functions own their returned key verbatim. "global" intentionally spans runtime processes using the same provider keyspace. Use a separate provider namespace or custom scope when applications share infrastructure but not resources.
An underivable userKey under "user" scope is a CapabilityBindingError pointing at the runtime's userKey option.
deriveCapabilityInstanceKey
function deriveCapabilityInstanceKey(input: {
capabilityId: string;
scope: CapabilityScope;
source: CapabilityKeySource;
}): string;Exports the same opaque key derivation used by the runtime. Capability packages use it when they expose a domain-specific service that must share scoped resources with agent operations. Application features should normally use commands or the package's service rather than deriving keys themselves.
CapabilityBindingError
class CapabilityBindingError extends Error {
readonly capabilityId: string;
readonly operationName?: string;
}Thrown at construction when capability declarations and bindings disagree — unbound ids, unknown binding keys, id mismatches, or one id declared through distinct capability objects. It backstops the compile-time seam for JavaScript and any consumers, and name is "CapabilityBindingError" for cross-realm checks.
Binding Conformance Suite
Binding authors validate the keying and lifecycle contract with the generic Vitest suite from @pai/core/test. This entrypoint is test-only and should not be imported from runtime code.
import { createCapabilityBindingConformanceSuite } from "@pai/core/test";
createCapabilityBindingConformanceSuite({
name: "sandboxCapability",
createBinding: () => sandboxCapability({ provider }),
instanceId: (ws) => ws.use(async (session) => session.sandboxId),
});type CapabilityBindingConformanceOptions<TFacade = unknown> = {
name: string;
/**
* Create a binding under test. Successive calls within one suite run must
* share durable backing state (for example one provider instance), so the
* suite can verify `close()` does not destroy durable instances.
*/
createBinding():
| CapabilityBinding<TFacade>
| Promise<CapabilityBinding<TFacade>>;
/**
* Stable identity of the underlying instance behind a facade — for example
* the provider resource id. Two facades for one key must report the same id
* until the instance is disposed.
*/
instanceId(facade: TFacade): string | Promise<string>;
/**
* Set to `false` when the capability's instances are purely in-process and
* cannot survive `close()`. Defaults to `true`, which asserts an instance
* acquired before `close()` is still reachable from a fresh binding.
*/
durableAcrossClose?: boolean;
};The suite is deliberately thin: per-call-fresh facades over one underlying instance per key, concurrency-safe parallel acquires, fresh provisioning after dispose, dispose/close idempotent and tolerant of unknown keys and reasons, and close() never destroying durable instances. Domain guarantees — expiry recovery, reconnects, resets — belong in the capability package's own suites, such as the @pai/sandbox provider conformance suite.