Agent capabilities

Agent capabilities expose narrow application actions to authenticated AI agent transports. They are another entrypoint into ordinary Beignet use cases, not a second business-logic layer or an agent orchestration framework.

Beignet owns the transport-neutral definition, validation, application context, execution, tracing, and instrumentation. An integration such as Better Auth Agent Auth owns agent registration, tokens, grants, constraints, and approval.

Define the agent-facing contract

Bind the app context and verified principal types once:

// lib/agent-capabilities.ts
import { createAgentCapabilities } from "@beignet/core/agent-capabilities";
import type { AppContext } from "@/app-context";

export type AgentPrincipal = {
  agentId: string;
  userId: string;
};

export const { defineAgentCapability, defineAgentCapabilityRegistry } =
  createAgentCapabilities<AppContext, AgentPrincipal>();

Keep definitions near the feature and delegate behavior to existing use cases. The capability schema may differ from an HTTP contract when an agent needs a curated input or output:

// features/issues/agent-capabilities.ts
import { z } from "zod";
import { createIssueUseCase } from "@/features/issues/use-cases";
import { defineAgentCapability } from "@/lib/agent-capabilities";

export const createIssueCapability = defineAgentCapability("issues.create", {
  description: "Create an issue in one workspace.",
  input: z.object({
    workspaceId: z.string().min(1),
    title: z.string().min(1).max(200),
  }),
  output: z.object({ id: z.string(), title: z.string() }),
  async handle({ ctx, input }) {
    const { workspaceId: _workspaceId, ...useCaseInput } = input;
    return createIssueUseCase.run({ ctx, input: useCaseInput });
  },
});

Both schemas are runtime boundaries. Arguments are parsed before context construction; handler results are parsed before they reach the transport.

Register and execute capabilities

Compose definitions explicitly and create one executor. The context resolver is the security boundary between a verified transport identity and your application's authorization state:

// server/agent-capabilities.ts
import {
  createAgentCapabilityExecutor,
} from "@beignet/core/agent-capabilities";
import { appError } from "@/features/shared/errors";
import { createIssueCapability } from "@/features/issues/agent-capabilities";
import { defineAgentCapabilityRegistry } from "@/lib/agent-capabilities";

export const agentCapabilityRegistry =
  defineAgentCapabilityRegistry([createIssueCapability]);

export const agentCapabilityExecutor = createAgentCapabilityExecutor({
  registry: agentCapabilityRegistry,
  async createContext({ principal, input }) {
    const server = await import("@/server").then(({ getServer }) => getServer());
    const membership = await server.ports.members.findMembership({
      workspaceId: input.workspaceId,
      userId: principal.userId,
    });
    if (!membership) throw appError("NotAWorkspaceMember");

    return server.createServiceContext({
      asUser: { id: principal.userId, role: membership.role },
      tenantId: input.workspaceId,
    });
  },
});

Never accept a tenant role from agent claims. Re-read membership from an authoritative app port, then use createServiceContext(...) so the server attaches the same gate and correlation state used by other entrypoints. See Routes and server.

execute(...) is typed by literal capability name for tests and in-process callers. Protocol adapters use executeDynamic(...) with an authorize(...) callback when transport authorization must inspect parsed input. Core invokes that callback against the exact parsed value before context construction, then uses the same value for the context and handler.

Better Auth Agent Auth

Install the bridge beside Better Auth's Agent Auth plugin:

bun add @beignet/agent-auth-better-auth @better-auth/agent-auth
import { agentAuth } from "@better-auth/agent-auth";
import { createBetterAuthAgentCapabilityAdapter } from "@beignet/agent-auth-better-auth";
import { APIError } from "better-auth/api";
import {
  agentCapabilityExecutor,
  agentCapabilityRegistry,
} from "@/server/agent-capabilities";

const capabilityBridge = createBetterAuthAgentCapabilityAdapter({
  registry: agentCapabilityRegistry,
  executor: agentCapabilityExecutor,
  principal({ agentSession }) {
    if (!agentSession.userId) {
      throw new APIError("FORBIDDEN", {
        message: "A delegated user is required.",
      });
    }
    return {
      agentId: agentSession.agentId,
      userId: agentSession.userId,
    };
  },
  metadata: {
    "issues.create": {
      approvalStrength: "session",
      requiredConstraints: ["workspaceId"],
    },
  },
});

agentAuth({
  ...capabilityBridge,
  providerName: "my-app",
  modes: ["delegated"],
});

When auth initialization and server composition depend on each other, pass a lazy executor instead of creating a proxy object in the app:

import { agentCapabilityRegistry } from "@/lib/agent-capability-registry";

const capabilityBridge = createBetterAuthAgentCapabilityAdapter({
  registry: agentCapabilityRegistry,
  executor: () =>
    import("@/server/agent-capabilities").then(
      ({ agentCapabilityExecutor }) => agentCapabilityExecutor,
    ),
  principal({ agentSession }) {
    if (!agentSession.userId) {
      throw new APIError("FORBIDDEN", {
        message: "A delegated user is required.",
      });
    }
    return { agentId: agentSession.agentId, userId: agentSession.userId };
  },
});

Keep the transport-safe registry in a module that does not import server composition; only the executor needs the dynamic server import. The first successful resolution is memoized for the warm module instance, including concurrent execution. Failed initialization is retried on the next request.

The bridge converts Zod schemas to JSON Schema during setup. Apps using another Standard Schema library pass a SchemaConverter. Unsupported schemas fail at boot with the capability name rather than failing on the first request.

Agent Auth evaluates grant constraints against raw arguments before calling the bridge. Beignet then verifies that the active grant still contains every field currently declared in requiredConstraints. Tightening capability metadata therefore makes older broad grants unusable at execution time. Revoke or migrate those stale grants so agents can request replacement grants with the newly required scope; Beignet does not mutate Agent Auth's persistence.

If validation changes a field constrained by the active grant, Beignet rejects the invocation before context construction or application work begins. Keep constrained identifiers and amounts non-transforming; unconstrained fields may still use normalization and other schema transformations. The bridge snapshots active constrained values before parsing once, so in-place mutation and a second parse cannot change the value used for execution.

The bridge supports Agent Auth's default capability execution endpoint. Custom capability locations are intentionally excluded because they route requests to app-owned handlers outside Beignet's validation, context, constraint, tracing, and output boundary.

Better Auth verifies the agent and active grant before calling the executor. The app still verifies that the represented user may access the requested tenant and relies on use cases and policies for business authorization.

Errors and observability

Core execution uses stable error codes for unknown capabilities, invalid input, invalid output, and execution failures. The Better Auth bridge maps malformed arguments to protocol-safe client errors and redacts invalid output and unexpected failures as internal errors. Declared Beignet AppError failures use their stable application code as the Agent Auth error code. Individual execution also preserves the HTTP status; batch execution preserves the per-item code but does not have a per-item HTTP status. Intentional Better Auth APIError failures remain public. Throw one of those explicit public errors when a failure is safe for an agent to receive; ordinary errors are always redacted.

Executor hooks observe start, completion, and failure across lookup, input, authorization, context, handler, and output stages. Successful completion events include the capability definition, application context, validated input, validated output, principal, and duration. Failure events include the validated input and context only when execution reached those stages; malformed raw input and unvalidated handler output are never exposed. Because early stages run before application context exists, failure events expose an optional ctx. Pass independent instrumentation and tracing options to the executor when malformed input or context-construction failures must reach those ports; otherwise Beignet derives them from ctx after successful context construction. Recorded attributes are bounded, and principal data, arguments, and results are never recorded automatically. Lifecycle hooks remain best-effort observers; place mandatory audit writes inside the application workflow.

Use the adapter-specific test helper when testing the Better Auth bridge:

import { createBetterAuthAgentCapabilityTestContext } from
  "@beignet/agent-auth-better-auth/testing";

await capabilityBridge.onExecute?.(
  createBetterAuthAgentCapabilityTestContext({
    capability: "issues.create",
    arguments: { workspaceId: "workspace_1", title: "Fix login" },
    constraints: { workspaceId: "workspace_1" },
  }),
);

The first release supports synchronous promise-based results. Streaming, asynchronous polling, model loops, prompts, conversation memory, and approval UI remain transport or application concerns.