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):
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>.mdmatching the template used by the six built-ins. - Add
test/workflows/handlers/<name>.test.tscovering the happy path and one failure mode. Integration viatest/workflows/dispatcher.test.tsis automatic — if the registry entry is valid, dispatch works. - The
check:docs-syncscript in CI fails any PR that touchessrc/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():
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:
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:
- Subscribe to the event in the GitHub App settings (Permissions & events).
- Add a webhook handler in
src/webhook/events/<event>.tsthat parses the payload and dispatches viadispatchByLabel(label path) ordispatchByIntent(comment path). Webhook handlers must return within 10 s — fireprocessRequestwith fire-and-forget semantics. - Register the event handler in
src/app.tsalongside the existingapp.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.