PAIPAI

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.

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:

ScopeBuilt-in identity
"thread"capability + scope + thread
"user"capability + scope + user
"scope"capability + scope
"global"capability, shared across one provider keyspace
functionits 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.

On this page