diff --git a/bun_client/frontend/src/components/PluginAnnotations.tsx b/bun_client/frontend/src/components/PluginAnnotations.tsx
new file mode 100644
index 0000000..1eda6c6
--- /dev/null
+++ b/bun_client/frontend/src/components/PluginAnnotations.tsx
@@ -0,0 +1,78 @@
+import { Badge } from '../design';
+import { annotationSeverityVariant, type PluginAnnotation } from '../plugin_utils';
+
+interface PluginAnnotationsProps {
+ annotations: PluginAnnotation[];
+ /** Called when an annotation is clicked, e.g. to jump to the line in the diff. */
+ onSelect?: (annotation: PluginAnnotation) => void;
+}
+
+/**
+ * Lists a plugin's line-level annotations beneath its body. Rendering these
+ * inline in the diff is separate follow-up work; this is the flat view on the
+ * plugin output surfaces.
+ */
+export default function PluginAnnotations({ annotations, onSelect }: PluginAnnotationsProps) {
+ if (annotations.length === 0) return null;
+
+ return (
+
',
+ });
+ });
+
+ test('falls back to the raw result as markdown when no body is present', () => {
+ // A server predating the response contract only sends result/status.
+ const plugin: PluginResult = { result: 'plain text summary', status: 'success' };
+ expect(resolvePluginBody(plugin)).toEqual({
+ body_type: 'markdown',
+ body_content: 'plain text summary',
+ });
+ });
+
+ test('falls back to the raw result when body_type is unrecognized', () => {
+ const plugin = {
+ result: 'plain text summary',
+ status: 'success',
+ body: { body_type: 'plaintext', body_content: 'ignored' },
+ } as unknown as PluginResult;
+ expect(resolvePluginBody(plugin)).toEqual({
+ body_type: 'markdown',
+ body_content: 'plain text summary',
+ });
+ });
+
+ test('normalizes body_type casing', () => {
+ const plugin = {
+ result: '',
+ status: 'success',
+ body: { body_type: 'HTML', body_content: 'x' },
+ } as unknown as PluginResult;
+ expect(resolvePluginBody(plugin).body_type).toBe('html');
+ });
+
+ test('keeps an empty body when the plugin only produced annotations', () => {
+ // An annotations-only plugin's raw result is the contract JSON, which
+ // must not leak into the rendered body.
+ const plugin: PluginResult = {
+ result: '{"body":{"body_type":"markdown","body_content":""},"annotations":[]}',
+ status: 'success',
+ body: { body_type: 'markdown', body_content: '' },
+ annotations: [{ filename: 'a.py', line: 4, severity: 'warning', content: 'x' }],
+ };
+ expect(resolvePluginBody(plugin).body_content).toBe('');
+ });
+
+ test('handles a pending plugin with no output at all', () => {
+ const plugin: PluginResult = { result: '', status: 'pending' };
+ expect(resolvePluginBody(plugin)).toEqual({ body_type: 'markdown', body_content: '' });
+ });
+});
+
+describe('sortedAnnotations', () => {
+ test('groups by filename then orders by line', () => {
+ const plugin: PluginResult = {
+ result: '',
+ status: 'success',
+ body: { body_type: 'markdown', body_content: '' },
+ annotations: [
+ { filename: 'b.py', line: 2, severity: 'info', content: 'b2' },
+ { filename: 'a.py', line: 75, severity: 'warning', content: 'a75' },
+ { filename: 'a.py', line: 7, severity: 'error', content: 'a7' },
+ ],
+ };
+ expect(sortedAnnotations(plugin).map(a => `${a.filename}:${a.line}`)).toEqual([
+ 'a.py:7',
+ 'a.py:75',
+ 'b.py:2',
+ ]);
+ });
+
+ test('returns an empty list when annotations are absent', () => {
+ expect(sortedAnnotations({ result: 'x', status: 'success' })).toEqual([]);
+ });
+
+ test('does not mutate the original array', () => {
+ const annotations = [
+ { filename: 'z.py', line: 1, severity: '', content: 'z' },
+ { filename: 'a.py', line: 1, severity: '', content: 'a' },
+ ];
+ sortedAnnotations({ result: '', status: 'success', annotations });
+ expect(annotations[0].filename).toBe('z.py');
+ });
+});
+
+describe('annotationSeverityVariant', () => {
+ test('maps known severities', () => {
+ expect(annotationSeverityVariant('error')).toBe('danger');
+ expect(annotationSeverityVariant('CRITICAL')).toBe('danger');
+ expect(annotationSeverityVariant('warning')).toBe('warning');
+ expect(annotationSeverityVariant('warn')).toBe('warning');
+ expect(annotationSeverityVariant('info')).toBe('info');
+ expect(annotationSeverityVariant('note')).toBe('info');
+ });
+
+ test('falls back to neutral for free-form severities', () => {
+ expect(annotationSeverityVariant('nitpick')).toBe('neutral');
+ expect(annotationSeverityVariant('')).toBe('neutral');
+ });
+});
+
+describe('totalAnnotationCount', () => {
+ test('sums annotations across plugins and ignores plugins without any', () => {
+ const plugins: Record = {
+ linter: {
+ result: '',
+ status: 'success',
+ annotations: [
+ { filename: 'a.py', line: 1, severity: 'error', content: 'x' },
+ { filename: 'a.py', line: 2, severity: 'error', content: 'y' },
+ ],
+ },
+ summarizer: { result: 'text', status: 'success' },
+ };
+ expect(totalAnnotationCount(plugins)).toBe(2);
+ });
+
+ test('is zero with no plugins', () => {
+ expect(totalAnnotationCount({})).toBe(0);
+ });
+});
diff --git a/bun_client/frontend/src/plugin_utils.ts b/bun_client/frontend/src/plugin_utils.ts
new file mode 100644
index 0000000..968aada
--- /dev/null
+++ b/bun_client/frontend/src/plugin_utils.ts
@@ -0,0 +1,96 @@
+/**
+ * Plugin response contract
+ *
+ * A plugin's stdout may be plain text, or JSON declaring how its body should be
+ * rendered plus line-level annotations on the diff (see docs/plugins.md). The
+ * server parses that contract and serves both the raw stdout (`result`) and the
+ * parsed `body` / `annotations` alongside it. The helpers here are the client's
+ * single interpretation of those fields.
+ */
+
+import type { StatusVariant } from './design';
+
+export type PluginBodyType = 'markdown' | 'html';
+
+export interface PluginBody {
+ body_type: PluginBodyType;
+ body_content: string;
+}
+
+export interface PluginAnnotation {
+ filename: string;
+ line: number;
+ severity: string;
+ content: string;
+}
+
+export interface PluginResult {
+ /** Raw captured stdout of the plugin, unchanged. */
+ result: string;
+ status: string;
+ /** Parsed body. Absent when talking to a server predating the contract. */
+ body?: PluginBody;
+ /** Diff annotations declared by the plugin; absent or empty for plain output. */
+ annotations?: PluginAnnotation[];
+}
+
+/** A plugin annotation as it arrives on a PR payload, tagged with its source plugin. */
+export interface PRAnnotation extends PluginAnnotation {
+ plugin: string;
+}
+
+/**
+ * Resolve which body to render for a plugin result.
+ *
+ * A server that speaks the contract always sends a body, and we trust it — an
+ * empty body_content with annotations attached is a legitimate response from an
+ * annotations-only plugin. Only when the body is missing or carries an
+ * unrecognized type do we fall back to rendering the raw stdout as markdown,
+ * which is what clients did before the contract existed.
+ */
+export function resolvePluginBody(plugin: PluginResult): PluginBody {
+ const bodyType = (plugin.body?.body_type || '').toLowerCase();
+ if (bodyType === 'markdown' || bodyType === 'html') {
+ return { body_type: bodyType, body_content: plugin.body?.body_content ?? '' };
+ }
+ return { body_type: 'markdown', body_content: plugin.result || '' };
+}
+
+/**
+ * Annotations of a plugin result in display order: grouped by file, then by
+ * ascending line number. Annotations that can't be anchored to a line are
+ * dropped by the server, so anything here has a filename and a line.
+ */
+export function sortedAnnotations(plugin: PluginResult): PluginAnnotation[] {
+ return [...(plugin.annotations ?? [])].sort(
+ (a, b) => a.filename.localeCompare(b.filename) || a.line - b.line
+ );
+}
+
+/**
+ * Badge variant for an annotation's severity. Severity is free-form in the
+ * contract, so unrecognized values render as neutral rather than being hidden.
+ */
+export function annotationSeverityVariant(severity: string): StatusVariant {
+ switch (severity.toLowerCase()) {
+ case 'error':
+ case 'critical':
+ case 'high':
+ return 'danger';
+ case 'warning':
+ case 'warn':
+ case 'medium':
+ return 'warning';
+ case 'info':
+ case 'note':
+ case 'low':
+ return 'info';
+ default:
+ return 'neutral';
+ }
+}
+
+/** Total number of annotations across every plugin, for summary counts. */
+export function totalAnnotationCount(plugins: Record): number {
+ return Object.values(plugins).reduce((sum, p) => sum + (p.annotations?.length ?? 0), 0);
+}
diff --git a/docs/plugins.md b/docs/plugins.md
index 195a9c7..84f2460 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -70,6 +70,60 @@ The `example_plugin` included in this repository demonstrates the interface and
When your plugin runs, its standard output (stdout) is captured and stored in the database. Clients can then retrieve and display this output when you are reviewing a PR. For example, in the web client, plugin outputs appear in a dedicated "Plugins" section for each PR.
+## Plugin Response Contract
+
+A plugin's stdout can be plain text, but a plugin may instead emit a JSON document matching the response contract. This lets it declare how its body should be rendered and attach line-level annotations to the PR diff:
+
+```json
+{
+ "body": {
+ "body_type": "markdown",
+ "body_content": "This is the response."
+ },
+ "annotations": [
+ {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"}
+ ]
+}
+```
+
+### `body` Object
+
+| Field | Type | Description |
+|----------------|--------|----------------------------------------------|
+| `body_type` | string | Either `markdown` or `html` (case-insensitive) |
+| `body_content` | string | The renderable output of the plugin |
+
+### `annotations` List (optional)
+
+Each annotation anchors a remark to a line of a file in the PR:
+
+| Field | Type | Description |
+|------------|--------|--------------------------------------------------------------|
+| `filename` | string | Path of the file within the repo (required) |
+| `line` | int | 1-based line number the annotation applies to (required) |
+| `severity` | string | Free-form severity, e.g. `info`, `warning`, `error` |
+| `content` | string | The annotation text |
+
+Annotations without a `filename` or a positive `line` are dropped, since they can't be anchored to the diff.
+
+### Backwards Compatibility
+
+Output that doesn't match the contract — plain text, invalid JSON, JSON without a `body` object, or an unknown `body_type` — is treated as legacy output and wrapped as:
+
+```json
+{"body": {"body_type": "markdown", "body_content": ""}, "annotations": []}
+```
+
+so existing plugins keep working unchanged. The raw stdout is always stored and returned as-is in the `result` field of `GetPluginOutput`; parsing happens when results are served to clients.
+
+Annotations from every successfully executed plugin for a PR are also aggregated into the `annotations` field of the `GetPR` response (each tagged with the plugin's name), so clients can render them into the diff.
+
+### Rendering in the Web Client
+
+The web client renders a `markdown` body as markdown and an `html` body inside a sandboxed frame that inherits the current theme. The frame withholds `allow-scripts`, so scripts and inline event handlers in a plugin's HTML never execute — plugin bodies are often LLM-generated text derived from a PR's diff and description, which is not trusted markup.
+
+Annotations are listed beneath each plugin's body on both the plugin output page and the review view's plugin drawer, sorted by file and line. Rendering them inline in the diff is separate work.
+
## On-Demand Plugins
By default, all configured plugins automatically run when a PR is fetched or when its commit changes (once per SHA). However, some plugins can be expensive to run (e.g., those making API calls to third-party services like Gemini or Claude).
diff --git a/docs/protocol.md b/docs/protocol.md
index 918afd2..6a54810 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -97,6 +97,17 @@ Fetches a pull request from GitHub and returns it as rendered content (including
| `comments` | []CommentJSON| List of structured PR active comments |
| `outdated_comments` | []CommentJSON| List of structured PR outdated comments |
| `reviews` | []ReviewJSON | List of submitted reviews |
+| `annotations` | []PRAnnotation | Diff annotations aggregated from every plugin that has already executed successfully for this PR (see [Plugins](plugins.md#plugin-response-contract)) |
+
+#### PRAnnotation Object
+
+| Field | Type | Description |
+|------------|--------|----------------------------------------------------------|
+| `plugin` | string | Name of the plugin that produced the annotation |
+| `filename` | string | Path of the file within the repo |
+| `line` | int | 1-based line number the annotation applies to |
+| `severity` | string | Free-form severity, e.g. `info`, `warning`, `error` |
+| `content` | string | The annotation text |
#### PRMetadata Object
@@ -498,13 +509,29 @@ Retrieves the output and status of all plugins for a specific pull request.
**Reply** (`GetPluginOutputReply`):
| Field | Type | Description |
|----------|-----------------------------|--------------------------------------------------------|
-| `output` | map[string]PluginResult | Map of plugin names to their respective results/status |
-
-#### `PluginResult` Object
-| Field | Type | Description |
-|----------|--------|----------------------------------------------------------------------|
-| `result` | string | The captured output (stdout/stderr) of the plugin |
-| `status` | string | Execution status: `pending`, `success`, or `error` |
+| `output` | map[string]PluginOutput | Map of plugin names to their respective results/status |
+
+#### `PluginOutput` Object
+| Field | Type | Description |
+|---------------|--------------|-----------------------------------------------------------------------------------|
+| `result` | string | The raw captured stdout of the plugin, unchanged (kept for older clients) |
+| `status` | string | Execution status: `pending`, `success`, `error`, or `deferred` |
+| `body` | PluginBody | The plugin's output parsed against the [response contract](plugins.md#plugin-response-contract); non-conforming output is wrapped as a `markdown` body holding the raw output |
+| `annotations` | []Annotation | Line-level diff annotations declared by the plugin (empty for legacy output) |
+
+#### `PluginBody` Object
+| Field | Type | Description |
+|----------------|--------|----------------------------------------|
+| `body_type` | string | Either `markdown` or `html` |
+| `body_content` | string | The renderable output of the plugin |
+
+#### `Annotation` Object
+| Field | Type | Description |
+|------------|--------|------------------------------------------------------|
+| `filename` | string | Path of the file within the repo |
+| `line` | int | 1-based line number the annotation applies to |
+| `severity` | string | Free-form severity, e.g. `info`, `warning`, `error` |
+| `content` | string | The annotation text |
---
diff --git a/server/plugin_contract.go b/server/plugin_contract.go
new file mode 100644
index 0000000..ff3572a
--- /dev/null
+++ b/server/plugin_contract.go
@@ -0,0 +1,157 @@
+package server
+
+import (
+ "crs/config"
+ "crs/database"
+ "encoding/json"
+ "sort"
+ "strings"
+)
+
+// Plugin response contract
+//
+// Plugins historically wrote free-form text to stdout and clients rendered it
+// verbatim. A plugin may now instead emit a JSON document matching the
+// contract below, which lets it declare how its body should be rendered and
+// attach line-level annotations to the PR diff:
+//
+// {
+// "body": {
+// "body_type": "markdown",
+// "body_content": "This is the response."
+// },
+// "annotations": [
+// {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"}
+// ]
+// }
+//
+// Output that does not match the contract — non-JSON output, JSON without a
+// body object, or an unknown body_type — is treated as legacy output and
+// wrapped as {"body_type": "markdown", "body_content": } with no
+// annotations, so existing plugins keep working unchanged.
+
+const (
+ BodyTypeMarkdown = "markdown"
+ BodyTypeHTML = "html"
+)
+
+// PluginBody is the renderable portion of a plugin response.
+type PluginBody struct {
+ // BodyType is either "markdown" or "html".
+ BodyType string `json:"body_type"`
+ BodyContent string `json:"body_content"`
+}
+
+// PluginAnnotation anchors a plugin remark to a line of a file in the PR.
+// Filename and Line are required for an annotation to be kept; Severity and
+// Content are free-form.
+type PluginAnnotation struct {
+ Filename string `json:"filename"`
+ Line int `json:"line"`
+ Severity string `json:"severity"`
+ Content string `json:"content"`
+}
+
+// PluginResponse is the parsed form of a plugin's stdout.
+type PluginResponse struct {
+ Body PluginBody `json:"body"`
+ Annotations []PluginAnnotation `json:"annotations"`
+}
+
+// rawPluginResponse mirrors PluginResponse with a pointer body so parsing can
+// tell "no body key" apart from an empty one.
+type rawPluginResponse struct {
+ Body *PluginBody `json:"body"`
+ Annotations []PluginAnnotation `json:"annotations"`
+}
+
+// ParsePluginOutput parses raw plugin stdout against the response contract.
+// Output that doesn't conform is returned whole as a markdown body with no
+// annotations. Annotations missing a filename or a positive line number are
+// dropped, since they can't be anchored to the diff.
+func ParsePluginOutput(raw string) PluginResponse {
+ fallback := PluginResponse{
+ Body: PluginBody{BodyType: BodyTypeMarkdown, BodyContent: raw},
+ Annotations: []PluginAnnotation{},
+ }
+
+ var parsed rawPluginResponse
+ if err := json.Unmarshal([]byte(raw), &parsed); err != nil || parsed.Body == nil {
+ return fallback
+ }
+ bodyType := strings.ToLower(strings.TrimSpace(parsed.Body.BodyType))
+ if bodyType != BodyTypeMarkdown && bodyType != BodyTypeHTML {
+ return fallback
+ }
+
+ response := PluginResponse{
+ Body: PluginBody{BodyType: bodyType, BodyContent: parsed.Body.BodyContent},
+ Annotations: []PluginAnnotation{},
+ }
+ for _, a := range parsed.Annotations {
+ if a.Filename == "" || a.Line < 1 {
+ continue
+ }
+ response.Annotations = append(response.Annotations, a)
+ }
+ return response
+}
+
+// PluginOutput is the client-facing view of a stored plugin result: the raw
+// captured stdout (kept so existing clients keep working) plus the parsed
+// response contract.
+type PluginOutput struct {
+ Result string `json:"result"`
+ Status string `json:"status"`
+ Body PluginBody `json:"body"`
+ Annotations []PluginAnnotation `json:"annotations"`
+}
+
+// parsePluginOutputs converts stored plugin results to their client-facing
+// view, parsing each stored result against the response contract.
+func parsePluginOutputs(results map[string]database.PluginResult) map[string]PluginOutput {
+ outputs := make(map[string]PluginOutput, len(results))
+ for name, res := range results {
+ parsed := ParsePluginOutput(res.Result)
+ outputs[name] = PluginOutput{
+ Result: res.Result,
+ Status: res.Status,
+ Body: parsed.Body,
+ Annotations: parsed.Annotations,
+ }
+ }
+ return outputs
+}
+
+// PRAnnotation is a plugin annotation aggregated into a PR reply, tagged with
+// the plugin that produced it.
+type PRAnnotation struct {
+ PluginAnnotation
+ Plugin string `json:"plugin"`
+}
+
+// collectPluginAnnotations gathers the annotations from every plugin that has
+// already run successfully for the PR, grouped by plugin in name order so the
+// result is deterministic.
+func collectPluginAnnotations(owner, repo string, number int) ([]PRAnnotation, error) {
+ results, err := config.C().DB.GetPluginResults(owner, repo, number)
+ if err != nil {
+ return nil, err
+ }
+
+ names := make([]string, 0, len(results))
+ for name, res := range results {
+ if res.Status == "success" {
+ names = append(names, name)
+ }
+ }
+ sort.Strings(names)
+
+ annotations := []PRAnnotation{}
+ for _, name := range names {
+ for _, a := range ParsePluginOutput(results[name].Result).Annotations {
+ annotations = append(annotations, PRAnnotation{PluginAnnotation: a, Plugin: name})
+ }
+ }
+ return annotations, nil
+}
diff --git a/server/plugin_contract_test.go b/server/plugin_contract_test.go
new file mode 100644
index 0000000..69776d0
--- /dev/null
+++ b/server/plugin_contract_test.go
@@ -0,0 +1,177 @@
+package server
+
+import (
+ "crs/config"
+ "testing"
+)
+
+func TestParsePluginOutput_ConformingMarkdownResponse(t *testing.T) {
+ raw := `{
+ "body": {"body_type": "markdown", "body_content": "This is the response."},
+ "annotations": [
+ {"filename": "test.py", "line": 75, "severity": "warning", "content": "this line looks wrong"},
+ {"filename": "main.go", "line": 3, "severity": "error", "content": "nil dereference"}
+ ]
+ }`
+ resp := ParsePluginOutput(raw)
+
+ if resp.Body.BodyType != BodyTypeMarkdown {
+ t.Errorf("expected body_type %q, got %q", BodyTypeMarkdown, resp.Body.BodyType)
+ }
+ if resp.Body.BodyContent != "This is the response." {
+ t.Errorf("unexpected body_content: %q", resp.Body.BodyContent)
+ }
+ if len(resp.Annotations) != 2 {
+ t.Fatalf("expected 2 annotations, got %d", len(resp.Annotations))
+ }
+ first := resp.Annotations[0]
+ if first.Filename != "test.py" || first.Line != 75 || first.Severity != "warning" || first.Content != "this line looks wrong" {
+ t.Errorf("unexpected first annotation: %+v", first)
+ }
+}
+
+func TestParsePluginOutput_ConformingHTMLResponse(t *testing.T) {
+ raw := `{"body": {"body_type": "HTML", "body_content": "