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(...) after their own identity and grant checks.

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"],
});

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. 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.

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, context, handler, and output stages. Because early stages run before application context exists, hook 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.

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