Plugins

Extend Better Agent with cross-agent behavior.

Plugins add behavior that applies across your entire app: logging, auth, rate limiting, lifecycle hooks, and custom endpoints. Register them once at the app level and they apply to every agent.

server.ts
import { betterAgent } from "@better-agent/core";
import { rateLimitPlugin, loggingPlugin } from "@better-agent/plugins";

export const app = betterAgent({
  agents: [myAgent],
  plugins: [
    rateLimitPlugin({ windowMs: 60_000, max: 20 }),
    loggingPlugin({ level: "info" }),
  ],
});

Creating a Plugin

Use definePlugin() to create a custom plugin. Every plugin needs a unique id.

plugins.ts
import { definePlugin } from "@better-agent/core";

export const myPlugin = definePlugin({
  id: "my-plugin",
});

A plugin can use any combination of: guards, lifecycle hooks, event middleware, event observation, tools, and endpoints.

Guards

Guards run before a request is processed. Return null to allow, or a Response to reject.

plugins.ts
const workspaceAuth = definePlugin({
  id: "workspace-auth",
  guards: [
    async ({ mode, agentName, request, input }) => {
      const token = request.headers.get("authorization");
      if (!token) {
        return new Response("Unauthorized", { status: 401 });
      }

      const workspace = await resolveWorkspace(token);
      if (!workspace) {
        return new Response("Invalid workspace", { status: 403 });
      }

      return null;
    },
  ],
});

The guard context includes:

  • mode: the request type: "run", "stream", "load_conversation", "abort_run", "resume_stream", or "resume_conversation"
  • agentName: resolved agent name
  • input: parsed request body
  • request: the raw Request object

Guard failures throw and stop the request. This is different from lifecycle hooks, which are swallowed on failure.

Lifecycle Hooks

Plugins can hook into every phase of the agent loop. All hooks run in plugin registration order, before the agent's own hooks.

onStep

Runs before each step. Can mutate messages, set tool choice, filter active tools, or override the system instruction.

plugins.ts
const contextInjector = definePlugin({
  id: "context-injector",
  onStep: async (ctx) => {
    if (ctx.stepIndex === 0) {
      ctx.updateMessages((messages) => [
        ...messages,
        {
          role: "system",
          content: `Current time: ${new Date().toISOString()}`,
        },
      ]);
    }
  },
});

Available methods on the onStep context:

  • updateMessages(updater): rewrite the message list
  • setToolChoice(choice): override tool choice for this step
  • setActiveTools(names): restrict which tools are available
  • setSystemInstruction(instruction): override the system instruction

onBeforeModelCall

Runs just before the provider call. Can mutate the model input, tools, and tool choice.

plugins.ts
const toolFilter = definePlugin({
  id: "tool-filter",
  onBeforeModelCall: async (ctx) => {
    const filtered = ctx.tools.filter((t) => t.name !== "dangerous_tool");
    ctx.setTools(filtered);
  },
});

onAfterModelCall

Observes the provider response after the model call completes.

plugins.ts
const usageTracker = definePlugin({
  id: "usage-tracker",
  onAfterModelCall: async (ctx) => {
    const usage = ctx.response.usage;
    if (usage) {
      await recordUsage(ctx.agentName, ctx.runId, usage);
    }
  },
});

onBeforeToolCall

Runs before a tool executes. Can modify args or skip the tool entirely.

plugins.ts
const toolGuard = definePlugin({
  id: "tool-guard",
  onBeforeToolCall: async (ctx) => {
    if (ctx.toolName === "delete_user" && !isAdmin(ctx)) {
      return { skip: true, result: { error: "Not authorized" } };
    }
    return undefined;
  },
});

Return { skip: true, result? } to skip execution and optionally provide a fallback result. Return undefined to proceed normally.

onAfterToolCall

Runs after a tool executes. Can observe or replace the result.

plugins.ts
const resultRedactor = definePlugin({
  id: "result-redactor",
  onAfterToolCall: async (ctx) => {
    if (ctx.toolName === "get_user" && ctx.result) {
      ctx.setResult(redactPII(ctx.result));
    }
  },
});

onBeforeSave

Runs before conversation items are persisted. Can rewrite or filter items.

plugins.ts
const saveFilter = definePlugin({
  id: "save-filter",
  onBeforeSave: async (ctx) => {
    ctx.setItems(
      ctx.items.filter((item) => item.type !== "tool_result" || !item.meta?.ephemeral),
    );
  },
});

All lifecycle hook contexts include runId, agentName, and conversationId. Plugin hooks receive agent context as unknown, so plugins cannot assume its shape.

Plugin hook contexts are broad by design. Some controls only appear when the active model supports them, like tool-mutation helpers on tool-capable runs.

Event Middleware

Use event middleware to transform or drop events before they reach the stream.

plugins.ts
const sensitiveFilter = definePlugin({
  id: "sensitive-filter",
  events: {
    subscribe: ["TOOL_CALL_RESULT"],
    middleware: [
      async (event, ctx, next) => {
        if (event.type === "TOOL_CALL_RESULT" && event.toolCallName === "get_secrets") {
          return next({ ...event, result: "[redacted]" });
        }
        return next(event);
      },
    ],
  },
});
  • subscribe filters which event types the middleware receives. Omit it to receive all events.
  • Return next(event) to pass the event through, optionally modified.
  • Return null to drop the event from the stream.

Event Observation

Use onEvent to observe committed events without mutating them. This runs after middleware.

plugins.ts
const analytics = definePlugin({
  id: "analytics",
  onEvent: async (event, ctx) => {
    await trackEvent({
      type: event.type,
      runId: ctx.runId,
      agent: ctx.agentName,
      timestamp: event.timestamp,
    });
  },
});

Plugin Tools

Plugins can provide tools to all agents. These are merged with each agent's own tools at runtime.

plugins.ts
const currentTime = defineTool({
  name: "current_time",
  description: "Returns the current UTC time",
  schema: z.object({}),
}).server(async () => {
  return { time: new Date().toISOString() };
});

const timeTool = definePlugin({
  id: "time-tool",
  tools: currentTime,
});

Endpoints

Plugins can add custom HTTP endpoints to the Better Agent server.

plugins.ts
const healthCheck = definePlugin({
  id: "health",
  endpoints: [
    {
      method: "GET",
      path: "/health",
      public: true,
      handler: async ({ request, params, query }) => {
        return Response.json({ status: "ok" });
      },
    },
  ],
});
  • path must start with /
  • method accepts GET, POST, PUT, PATCH, DELETE, OPTIONS or an array
  • public: true bypasses the app's built-in bearer auth for this endpoint
  • handler receives { request, params, query }

Error Behavior

Lifecycle hooks and event middleware are fault-tolerant. If a hook throws, Better Agent logs the error and continues the run. This ensures plugins never break agent execution.

Guards are the exception. A guard failure throws an error and stops the request. Design guards to handle their own errors if you want fail-open behavior.