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