PAIPAI

Capabilities

Typed runtime-bound services that tools and commands declare and the runtime provides.

A capability is a named, typed requirement a tool or command declares and createAgentRuntime binds to a concrete implementation — the way storage, files, and realtime bind today. The operation consumes a per-call facade at ctx.capabilities.<name>; the runtime owns where the resource comes from and when it is destroyed.

import { defineTool } from "@pai/core";
import { sandbox } from "@pai/sandbox";
import { z } from "zod";

const runScript = defineTool({
  id: "runScript",
  description: "Run a command in this conversation's sandbox.",
  inputSchema: z.object({ command: z.string() }),
  capabilities: { sandbox },
  execute: (ctx) =>
    ctx.capabilities.sandbox.runCommand({ command: ctx.input.command }),
});

Mental Model

operation declares capabilities: { sandbox }              (defineTool/defineCommand)
agent infers       id-keyed requirement map               (defineAgent)
runtime binds      capabilities: { sandbox: binding }     (createAgentRuntime)
executor acquires  binding.acquire({ key, ... })          (per operation)
operation consumes ctx.capabilities.sandbox               (typed facade)
  • Operations declare. capabilities: { sandbox } names a capability token in a tool or command config. The map key is the operation-local ctx name; the token's id is its stable identity.
  • Agents infer. defineAgent folds every tool and command declaration into one id-keyed requirement map carried on the agent's type. There is no new defineAgent syntax.
  • Runtimes bind. createAgentRuntime requires a typed capabilities record exactly when the agent's operations declare capabilities. Forgetting to bind, binding a wrong facade type, or binding an undeclared id is a compile error at the createAgentRuntime call.
  • Operations consume. Before a handler runs, the runtime derives the instance key from the binding's scope, acquires all declared capabilities in parallel, and places the facades at ctx.capabilities.

Capabilities are a server concern. They never appear in the agent contract, manifest, protocol, or client bundles.

Declaring Capabilities

The whole declaration is one config field. The map key becomes the ctx name, so tokens are aliasable:

const tool = defineTool({
  id: "listSandboxDirectory",
  description: "...",
  inputSchema: z.object({ path: z.string() }),
  capabilities: { box: sandbox },
  execute: (ctx) => ctx.capabilities.box.listDir({ path: ctx.input.path }),
});

Tools and commands that declare the same capability id share one binding at the runtime, regardless of their local names.

capabilities requires execute — facades exist only for backend tool execution, so declaring capabilities on an external-output tool is a construction-time error.

capabilities and context/mapContext are orthogonal. context carries app data the agent builds per run; capabilities carries runtime-bound services with lifecycle. A tool can use both.

Binding At The Runtime

The binding record is where concrete infrastructure enters, mirroring storage and files:

import { createAgentRuntime } from "@pai/core";
import { sandboxCapability } from "@pai/sandbox";
import { createE2BSandboxProvider } from "@pai/sandbox-e2b";

const runtime = createAgentRuntime({
  agent,
  storage,
  scopeKey: (identity) => identity.workspaceId,
  capabilities: {
    sandbox: sandboxCapability({ provider: createE2BSandboxProvider() }),
  },
});

All typing flows from the agent value — no hand-supplied generics. Omitting a required entry fails with Property 'sandbox' is missing; a binding whose facade type does not match fails at that record key; extra bindings are rejected. A construction-time walk of the agent's tools and commands re-validates the record for JavaScript and any consumers and throws CapabilityBindingError listing unbound ids.

Because the binding is chosen at the runtime, environments swap one record value: production binds a real provider, tests bind an in-memory one, and the agent module never changes.

Bind capabilities in createPai({ agents: { id: { agent, capabilities } } }) so the app registry owns runtime construction and the HTTP adapter only mounts the finished pai app.

Scopes And Instance Keys

A binding's scope decides how operations map to underlying resource instances. Core derives the instance key and passes it to the binding; the binding maps keys to resources.

ScopeBuilt-in identityOne instance per
"thread" (default)capability + scope + threadconversation within a scope
"user"capability + scope + useruser within a scope
"scope"capability + scopetenant / product boundary
"global"capabilityprovider keyspace
functionits return value, verbatimcustom policy

Scope is chosen at binding time — it is deployment policy, not agent semantics. The same agent runs per-thread sandboxes in production and one shared box in a demo by changing one option. "global" intentionally addresses one instance per provider keyspace, including across runtime processes; apps sharing provider infrastructure but not resources must use separate provider namespaces or a custom scope. Core serializes every built-in identity into one opaque, provider-safe key. The encoding includes the capability id and scope kind, is total for JavaScript strings, and cannot collide for distinct identity tuples. Bindings should compare or persist the key, never parse it. Custom scope functions own their returned key verbatim.

"user" scope needs a derivable user key; if createAgentRuntime({ userKey }) cannot produce one, acquisition fails with a CapabilityBindingError pointing at that option.

Lifecycle

acquire runs once per operation per declared capability and must be cheap — bindings return a lazy facade and provision the underlying resource on first use. Tool acquisition failures follow the model-facing tool-error path; command failures reject the explicit caller.

The runtime implements the lifecycle call sites; the binding packages the reactions; apps write nothing:

  • Thread deleted — after the storage delete succeeds, the runtime signals process-local active work and gives it a bounded settlement window before every "thread"-scoped binding with a dispose hook receives dispose({ reason: "thread-deleted", key, threadId }). Non-thread scopes are not disposed — the thread does not own those instances. Cancellation is cooperative. Disposal is best-effort: failures are routed to onRuntimeError as a source: "capability" event and never fail the delete. Await deletion before deliberately reusing the same threadId, because thread scope is a logical { scopeKey, threadId } address rather than a persisted-incarnation key.
  • Runtime shutdownruntime.close() awaits binding.close() on every binding before tearing down storage and realtime. close releases local resources only, never durable state.

Application Access

For thread-scoped product operations, prefer a server-only command. It receives trusted identity and the same scoped facade without requiring callers to derive capability keys:

const resetWorkspace = defineCommand({
  input: z.object({}),
  capabilities: { sandbox },
  run: ({ capabilities }) => capabilities.sandbox.reset(),
});

await runtime
  .admin({ identity })
  .thread(threadId)
  .commands.resetWorkspace({});

For work that genuinely cannot run as a command, use a specialized service from the capability package rather than a generic runtime accessor. This keeps scope derivation and lifecycle policy inside the domain package. For example, createSandboxService() supports attachment staging before a model run and provides the binding registered with the runtime:

const sandboxes = createSandboxService({ provider, scope: "thread" });

const runtime = createAgentRuntime({
  agent,
  scopeKey: (identity) => identity.workspaceId,
  capabilities: { sandbox: sandboxes.binding },
});

const workspace = sandboxes.acquire({ scopeKey, threadId });

Most application features should remain commands. A specialized service is for pipeline integration or non-thread-scoped operational work, not a second general-purpose API for invoking capabilities.

Defining A Capability

Capability packages — and apps with private service wiring — declare a token with defineCapability and ship a binding factory. The token is a pure declaration; the facade type is a phantom witness:

import { createCapabilityBinding, defineCapability } from "@pai/core";

export type SqlWorkbench = {
  query(sql: string): Promise<Row[]>;
};

export const db = defineCapability<SqlWorkbench>()({
  id: "db",
  description: "Read-only SQL access to the tenant database.",
});

export function dbCapability(input: { connect(key: string): Pool }) {
  const pools = new Map<string, Pool>();
  return createCapabilityBinding<SqlWorkbench>({
    capability: db,
    scope: "scope", // one pool per tenant
    acquire: ({ key }) => {
      const pool = pools.get(key) ?? input.connect(key);
      pools.set(key, pool);
      return { query: (sql) => pool.query(sql) };
    },
    close: async () => {
      await Promise.all([...pools.values()].map((pool) => pool.end()));
      pools.clear();
    },
  });
}

Design the facade for operation bodies: build reconnect, retry, and expiry semantics into it once so every consuming tool and command inherits them. @pai/sandbox's Sandbox facade is the model — see the sandbox reference.

Use bare ids ("sandbox", "browser") for first-party capabilities and prefixed ids ("acme.vault") for third parties. Full type reference: Capabilities API.

Testing

Runtime tests keep the production agent verbatim and swap the binding:

import { createTestRuntime } from "@pai/test-utils";
import { sandboxCapability } from "@pai/sandbox";
import { createInMemorySandboxProvider } from "@pai/sandbox-inmemory";

await using runtime = createTestRuntime({
  agent,
  model,
  capabilities: {
    sandbox: sandboxCapability({ provider: createInMemorySandboxProvider() }),
  },
});

Tool tests supply facades directly. runTool requires a capabilities value exactly when the tool declares capabilities, typed by the tool-local ctx names — fakes are welcome, and capability packages usually export a cheap real facade:

import { runTool } from "@pai/test-utils";
import { createSandbox, createSandboxSessionManager } from "@pai/sandbox";
import { createInMemorySandboxProvider } from "@pai/sandbox-inmemory";

const sessions = createSandboxSessionManager({
  provider: createInMemorySandboxProvider(),
});

const tool = await runTool(runScript, {
  input: { command: "echo hi" },
  capabilities: { sandbox: createSandbox(sessions.handle("t")) },
});

expect(tool.expectOutput().stdout).toBe("hi\n");

Binding authors validate the keying and lifecycle contract with the generic conformance suite from @pai/core/test — see Capabilities API.

When Not To Use A Capability

Capabilities are for stateful, lifecycle-bound resources keyed to an identity — a sandbox per thread, a browser per user, a connection pool per tenant. The seam earns its weight when something must be provisioned, reused across operations, and destroyed at the right moment.

Plain context/mapContext remains the right tool for everything else:

  • stateless clients and SDKs (an HTTP client, a search client);
  • repositories and app services that already manage their own pooling;
  • per-request data (the loaded workspace, feature flags, loggers).

If nothing needs dispose or close and there is no instance keying, pass it through context.

On this page