Skip to content

Plugin architecture: self-contained profile packages #1

@AztecBot

Description

@AztecBot

Problem

Profile-specific logic is scattered across the codebase:

  • server.ts interprets profile manifests and routes channels
  • http-routes.ts has /api/audit/* endpoints hardcoded (200+ lines)
  • audit-dashboard.ts lives in libclaudebox/html/ (generic lib) despite being audit-specific
  • host-manifest.ts declares channel binding but server.ts interprets it separately
  • mcp-sidecar.ts runs inside container, disconnected from the rest

Adding a new profile means touching 4+ files across different packages. The channel-to-profile binding is declarative, but everything it does is imperative — that's the tension.

Design

Plugin interface

Minimal — just a name and a setup function:

interface Plugin {
  name: string;
  setup(ctx: PluginContext): void | Promise<void>;
}

interface PluginContext {
  // Raw event listeners — plugin decides what to do
  onSlackMessage(handler: (msg: SlackMessage) => Promise<boolean | void>): void;
  onSlackReaction(handler: (reaction: SlackReaction) => Promise<boolean | void>): void;

  // HTTP route registration
  route(method: string, path: string, handler: RouteHandler): void;

  // Shared infra — the plugin doesn't own these, it uses them
  docker: DockerManager;
  store: SessionStore;
  slack: SlackClient;
}

No channels field. Channel filtering is just an if statement inside the handler — plain code, no framework magic.

barretenberg-audit as a plugin

profiles/barretenberg-audit/
  plugin.ts          ← entry point, composes everything
  mcp-sidecar.ts     ← runs inside container (unchanged)
  container-claude.md
  html/
    dashboard.ts     ← moves FROM libclaudebox/html/
  routes/
    coverage.ts      ← /api/audit/coverage handler
    findings.ts      ← /api/audit/findings handler  
    questions.ts     ← /api/audit/questions handler
// profiles/barretenberg-audit/plugin.ts
const plugin: Plugin = {
  name: "barretenberg-audit",

  setup(ctx) {
    ctx.onSlackMessage(async (msg) => {
      if (msg.channel !== "C0AJCUKUNGP") return false; // not ours
      const session = await ctx.docker.startSession({
        prompt: msg.text,
        user: msg.user,
        extraEnv: ["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1"],
        sidecar: import.meta.dirname + "/mcp-sidecar.ts",
      });
      await ctx.slack.reply(msg.thread, `Audit started: ${session.url}`);
      return true;
    });

    ctx.route("GET",  "/audit",              (req, res) => res.html(auditDashboardHTML()));
    ctx.route("GET",  "/api/audit/coverage", coverageHandler(ctx.store));
    ctx.route("GET",  "/api/audit/findings", findingsHandler);
    ctx.route("POST", "/api/audit/questions/:id/answer", answerHandler(ctx.store));
  },
};

Server becomes a thin shell

const plugins = await loadPlugins(["barretenberg-audit", "default"]);

for (const p of plugins) {
  await p.setup(pluginContext);
}

// Handlers fire in registration order.
// barretenberg-audit claims its channel, returns true.
// Default handler gets everything else.

What stays in libclaudebox (shared infra)

  • DockerManager — container lifecycle
  • SessionStore — session JSONL, worktree management
  • SlackClient — posting messages, reactions
  • html/shared.tslinkify(), esc(), timeAgo(), base styles
  • html/app-shell.ts — page chrome, auth wrapper
  • mcp/base.ts — shared MCP tool framework (used by sidecars)

Plugins compose on top of these primitives.

Key design decisions

  • No declarative channel binding — channel/repo/user filtering is plain if statements in the handler. Default profile has no restrictions.
  • Ordering = priority — plugins loaded first get first crack at events. First handler to return true claims the event.
  • setup() is imperative — the plugin calls ctx.route() and ctx.onSlackMessage(). No manifest to interpret.
  • Composable internals — a plugin's setup() can compose smaller functions (setupCoverageRoutes(ctx), etc.) but that's the plugin's business.

Migration path

  1. Define Plugin + PluginContext interfaces in libclaudebox
  2. Move audit routes from http-routes.tsprofiles/barretenberg-audit/routes/
  3. Move audit-dashboard.tsprofiles/barretenberg-audit/html/
  4. Write profiles/barretenberg-audit/plugin.ts that registers everything
  5. Server loads plugins, dispatches events through handler chain
  6. Default profile extracted last (current behavior, just wrapped)

No big bang needed — barretenberg-audit migrates first, default stays as-is until later.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions