Skip to content

Extending

Two extension points in this codebase: workflow handlers and MCP servers. Both follow a consistent registry pattern.

Adding a workflow

A workflow is a verb the bot performs on a target (issue or PR). Six are registered today; adding a seventh is appending one entry to src/workflows/registry.ts plus a handler file.

Step 1 — write the handler

src/workflows/handlers/<name>.ts exports a WorkflowHandler (src/workflows/registry.ts):

export type WorkflowHandler = (ctx: WorkflowRunContext) => Promise<HandlerResult>;

WorkflowRunContext carries:

Field Type Notes
runId string Unique run identifier.
workflowName WorkflowName Which workflow is executing.
target {type: "issue" \| "pr", owner, repo, number} GitHub entity.
parent {runId, stepIndex} | undefined Set when this is a child step of a composite.
logger pino.Logger Structured logging.
octokit Octokit API client with installation token.
deliveryId string | null Webhook delivery id for tracing.
daemonId string Daemon process id.
setState(state, humanMessage) function Persist partial state mid-execution.

HandlerResult is a discriminated union:

| { status: "succeeded"; state: unknown; humanMessage?: string }
| { status: "failed"; reason: string; state?: unknown; humanMessage?: string }
| { status: "handed-off"; state?: unknown; humanMessage?: string; childRunId: string }

Capture exactly one Markdown artifact (<NAME>.md) so the tracking comment is self-documenting; the executor finalises the comment with state.report if present.

Step 2 — register

Append one RegistryEntry to rawRegistry in src/workflows/registry.ts:

{
  name: "my-verb",
  label: "bot:my-verb",
  context: "pr",            // "issue" | "pr" | "both"
  requiresPrior: null,      // or another WorkflowName
  steps: [],                // composite workflows fill this
  handler: myVerbHandler,
}

The Zod schema validates at module load — a mistyped entry fails the process at boot.

Field Type Notes
name WorkflowName (enum) Add to WorkflowNameSchema first.
label ^bot:[a-z]+$ Hyphens allowed.
context "issue" \| "pr" \| "both" Where the workflow may run.
requiresPrior WorkflowName \| null Workflow that must have succeeded first.
steps WorkflowName[] Empty for leaf; populated for composite.
handler WorkflowHandler Function reference.

Step 3 — make it discoverable from comments

If the workflow should be reachable via mentions, extend the system prompt in src/workflows/intent-classifier.ts with at least three fixture comments and add it to test/workflows/fixtures/intent-comments.json. The enum the classifier returns is driven by the registry; the prompt narrative just needs to mention the new verb so the classifier picks it.

Step 4 — document and test

  • Add docs/use/workflows/<name>.md matching the template used by the six built-ins.
  • Add test/workflows/handlers/<name>.test.ts covering the happy path and one failure mode. Integration via test/workflows/dispatcher.test.ts is automatic — if the registry entry is valid, dispatch works.
  • The check:docs-sync script in CI fails any PR that touches src/workflows/** without updating the workflow docs tree.

Adding an MCP server

The MCP registry lives at src/mcp/registry.ts. resolveMcpServers() returns a Map<name, McpServerDef> for the current request, conditionally activating servers based on context, options, and config.

Existing servers

Name Transport Purpose
comment_update stdio Updates the tracking comment owned by the bot. Always on.
inline_comments stdio Posts inline review comments and replies on PR diffs. PR runs only.
resolve_review_thread stdio Resolves a single PR review thread — bound to one (owner, repo, pullNumber) per server instance. Wired by resolve.ts.
daemon_capabilities stdio Reports the executing daemon's local environment (CPU, memory, language toolchain) to the agent.
repo_memory stdio Persistent per-repo memory keyed by (owner, repo, category). Backed by the repo_memory Postgres table.
context7 http Library documentation snippets via Upstash Context7. Auto-skipped when CONTEXT7_API_KEY is unset.

Transport types

Type When to use Example
stdio Local process; needs per-request secrets injected via env vars. comment_update.
http Remote service with a stable URL; no per-request process needed. context7.

Option A — stdio server

src/mcp/servers/<name>.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const MY_VAR = process.env["MY_VAR"];
if (!MY_VAR) {
  console.error("Error: MY_VAR is required");
  process.exit(1);
}

const server = new McpServer({ name: "My Server", version: "1.0.0" });

server.tool(
  "my_tool",
  "Description Claude sees when deciding whether to use this tool",
  { input: z.string().describe("Tool input") },
  async ({ input }) => ({ content: [{ type: "text" as const, text: "result" }] }),
);

const transport = new StdioServerTransport();
await server.connect(transport);
process.on("exit", () => void server.close());

Add a helper in src/mcp/registry.ts:

function myServerDef(sharedEnv: Record<string, string>): McpServerDef {
  return {
    type: "stdio",
    command: "bun",
    args: ["run", "src/mcp/servers/my-server.ts"],
    env: { ...sharedEnv, MY_VAR: "value" },
  };
}

Then add the conditional to resolveMcpServers():

servers["my_server"] = myServerDef(sharedEnv);

sharedEnv already carries GITHUB_TOKEN, REPO_OWNER, REPO_NAME, and GITHUB_EVENT_NAME.

The Dockerfile copies all of src/mcp/ to the production image, so new server files are picked up automatically. No Dockerfile change is needed.

Option B — HTTP server

export function myRemoteServer(): McpServerDef {
  return {
    type: "http",
    url: "https://my-service.example.com/mcp",
    headers: { Authorization: `Bearer ${process.env["MY_API_KEY"]}` },
  };
}

Register conditionally on credentials:

if (config.myApiKey) {
  servers["my_remote"] = myRemoteServer();
}

Add the env var to src/config.ts following the existing context7ApiKey pattern, document it in ../operate/configuration.md, and you're done.

The webhook → workflow boundary

If your extension reacts to a GitHub event the bot does not yet handle (e.g. push, pull_request_target), the work splits in two:

  1. Subscribe to the event in the GitHub App settings (Permissions & events).
  2. Add a webhook handler in src/webhook/events/<event>.ts that parses the payload and dispatches via dispatchByLabel (label path) or dispatchByIntent (comment path). Webhook handlers must return within 10 s — fire processRequest with fire-and-forget semantics.
  3. Register the event handler in src/app.ts alongside the existing app.webhooks.on(...) calls.

Webhook handlers do not run business logic — they parse the event, build a BotContext, and dispatch. All bot work happens in workflow handlers, called from the daemon.