diff --git a/agent/tests/test_write_confirmation.py b/agent/tests/test_write_confirmation.py
index 0f84d702..aa131d67 100644
--- a/agent/tests/test_write_confirmation.py
+++ b/agent/tests/test_write_confirmation.py
@@ -5,6 +5,71 @@
import write_confirmation
from langchain_core.tools import StructuredTool
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
+from write_confirmation import summarize_args
+
+
+def test_summarize_args_omits_empty_values():
+ fields = summarize_args(
+ {
+ "name": "OpenTag",
+ "color": "",
+ "id": None,
+ "labels": [],
+ "metadata": {},
+ }
+ )
+
+ assert fields == [{"label": "Name", "value": "OpenTag"}]
+
+
+def test_summarize_args_keeps_zero_and_false():
+ fields = summarize_args({"priority": 0, "archived": False})
+
+ assert fields == [
+ {"label": "Priority", "value": "0"},
+ {"label": "Archived", "value": "No"},
+ ]
+
+
+def test_summarize_args_humanizes_camel_case_and_snake_case_keys():
+ fields = summarize_args({"addTeams": ["CopilotKit"], "due_date": "2026-08-03"})
+
+ assert [f["label"] for f in fields] == ["Add teams", "Due date"]
+
+
+def test_summarize_args_joins_lists_and_compacts_nested_values():
+ fields = summarize_args(
+ {
+ "teams": ["CopilotKit", "Growth"],
+ "lead": {"email": "jerel@copilotkit.ai"},
+ }
+ )
+
+ assert fields[0] == {"label": "Teams", "value": "CopilotKit, Growth"}
+ assert fields[1] == {
+ "label": "Lead",
+ "value": '{"email": "jerel@copilotkit.ai"}',
+ }
+
+
+def test_summarize_args_truncates_an_overlong_value():
+ fields = summarize_args({"description": "x" * 400})
+
+ assert len(fields) == 1
+ assert fields[0]["value"] == "x" * 300 + "…"
+
+
+def test_summarize_args_caps_rows_and_notes_the_overflow():
+ fields = summarize_args({f"field{i}": f"value{i}" for i in range(15)})
+
+ assert len(fields) == 13
+ assert fields[12] == {"label": "…", "value": "3 more fields"}
+
+
+def test_summarize_args_renders_booleans_as_yes_and_no():
+ fields = summarize_args({"notify": True})
+
+ assert fields == [{"label": "Notify", "value": "Yes"}]
def test_write_confirmation_emits_the_copilotkit_interrupt_envelope(monkeypatch):
@@ -41,7 +106,7 @@ async def handler(request):
"action": "confirm_write",
"args": {
"action": "Create issue",
- "detail": '{"title": "Checkout 500s"}',
+ "fields": [{"label": "Title", "value": "Checkout 500s"}],
},
}
@@ -121,7 +186,7 @@ async def handler(request):
"action": "confirm_write",
"args": {
"action": "Create issue",
- "detail": '{"title": "Checkout 500s"}',
+ "fields": [{"label": "Title", "value": "Checkout 500s"}],
},
}
]
diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py
index 0dbba5e9..e7db6d11 100644
--- a/agent/write_confirmation.py
+++ b/agent/write_confirmation.py
@@ -1,6 +1,7 @@
"""Approval enforcement for mutating MCP tools."""
import json
+import re
from copilotkit.langgraph import copilotkit_interrupt
from langchain_core.tools import BaseTool
@@ -11,6 +12,70 @@
from mcp.types import CallToolResult, TextContent
+# Max rows rendered in the confirmation table; the rest are counted in a note.
+_MAX_FIELDS = 12
+
+# Longest value rendered in a cell before it is elided.
+_MAX_VALUE = 300
+
+
+def _humanize(key: str) -> str:
+ """`addTeams` / `due_date` -> `Add teams` / `Due date`."""
+ spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", key)
+ spaced = spaced.replace("_", " ").replace("-", " ").strip()
+ words = spaced.split()
+ if not words:
+ return key
+ first, *rest = words
+ return " ".join([first[:1].upper() + first[1:], *(w.lower() for w in rest)])
+
+
+def _is_empty(value) -> bool:
+ """Carries no information for an approver.
+
+ Deliberately not Python falsiness: `0` is a real Linear priority ("No
+ priority") and `False` a real flag value, so both must survive.
+ """
+ if value is None:
+ return True
+ return isinstance(value, (str, list, tuple, dict, set)) and len(value) == 0
+
+
+def _stringify(value) -> str:
+ if isinstance(value, bool):
+ return "Yes" if value else "No"
+ if isinstance(value, (list, tuple)):
+ return ", ".join(str(v) for v in value)
+ if isinstance(value, dict):
+ return json.dumps(value, ensure_ascii=False, default=str)
+ return str(value)
+
+
+def summarize_args(args: dict) -> list[dict]:
+ """Render mutating-tool args as approver-readable `{label, value}` rows.
+
+ Empty values are dropped so the two or three fields that matter aren't
+ buried among defaults, and the row count is capped so Slack doesn't collapse
+ the card behind "Show more" — an approver who can't read the payload can't
+ meaningfully approve it.
+ """
+ fields = []
+ for key, value in args.items():
+ if _is_empty(value):
+ continue
+ text = _stringify(value)
+ if len(text) > _MAX_VALUE:
+ text = text[:_MAX_VALUE] + "…"
+ fields.append({"label": _humanize(key), "value": text})
+
+ if len(fields) > _MAX_FIELDS:
+ hidden = len(fields) - _MAX_FIELDS
+ fields = fields[:_MAX_FIELDS]
+ fields.append({"label": "…", "value": f"{hidden} more fields"})
+
+ return fields
+
+
class WriteConfirmationInterceptor:
"""Require approval for every MCP tool not marked read-only."""
@@ -39,15 +104,9 @@ async def __call__(
action = request.name.replace("_", " ").replace("-", " ").strip()
action = action[:1].upper() + action[1:]
- detail = json.dumps(
- request.args,
- ensure_ascii=False,
- sort_keys=True,
- default=str,
- )
_answer, response = copilotkit_interrupt(
action="confirm_write",
- args={"action": action, "detail": detail},
+ args={"action": action, "fields": summarize_args(request.args)},
)
if isinstance(response, str):
diff --git a/app/channel.test.ts b/app/channel.test.ts
index fc2d492a..69ed33dc 100644
--- a/app/channel.test.ts
+++ b/app/channel.test.ts
@@ -334,6 +334,52 @@ describe("createOpenTagChannel", () => {
expect(JSON.stringify(blocks)).toContain("CPK-9: Checkout 500s");
});
+ it("renders the interrupt's fields as a table on the posted card", async () => {
+ const envelope = {
+ __copilotkit_interrupt_value__: {
+ action: "confirm_write",
+ args: {
+ action: "Save project",
+ fields: [
+ { label: "Name", value: "OpenTag" },
+ { label: "Lead", value: "jerel@copilotkit.ai" },
+ ],
+ },
+ },
+ __copilotkit_messages__: [],
+ };
+ const agent = new FakeAgent([
+ (subscriber) => {
+ subscriber.onCustomEvent?.({
+ event: {
+ type: EventType.CUSTOM,
+ name: "on_interrupt",
+ value: JSON.stringify(envelope),
+ },
+ } as never);
+ },
+ ]);
+ const { adapter, channel } = makeChannel({ agent });
+
+ await channel.ɵruntime.start();
+ await adapter.getSink().onTurn({
+ conversationKey: "c1",
+ replyTarget: {},
+ userText: "save it",
+ platform: "slack",
+ });
+
+ expect(adapter.posted).toHaveLength(1);
+ const { blocks } = renderSlackMessage(adapter.posted[0]!);
+ const table = blocks.find((b) => b.type === "table") as
+ | { rows: { text: string }[][] }
+ | undefined;
+ expect(table?.rows.map((row) => row.map((cell) => cell.text))).toEqual([
+ ["Name", "OpenTag"],
+ ["Lead", "jerel@copilotkit.ai"],
+ ]);
+ });
+
it("rejects malformed confirm_write interrupt payloads", async () => {
const consoleError = vi
.spyOn(console, "error")
diff --git a/app/channel.tsx b/app/channel.tsx
index c7771e69..3b6168e6 100644
--- a/app/channel.tsx
+++ b/app/channel.tsx
@@ -64,6 +64,7 @@ export function createOpenTagChannel(
await thread.post(
,
);
diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx
index 8be2f8f7..f43852f8 100644
--- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx
+++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx
@@ -84,7 +84,7 @@ describe("ConfirmWrite", () => {
| { elements: { text: string }[] }
| undefined;
expect(context?.elements[0]?.text).toContain(
- "Nothing is written until you click",
+ "Nothing is changed until you click",
);
// "Create" is authored as Markdown bold (`**Create**`) so the IR→mrkdwn
// transform renders it as Slack bold (`*Create*`), matching the old card.
@@ -106,6 +106,135 @@ describe("ConfirmWrite", () => {
expect(blocks.some((b) => b.type === "section")).toBe(false);
});
+ it("labels the confirm button with the action's own verb", () => {
+ const ir = renderToIR( );
+ const { blocks } = renderSlackMessage(ir);
+
+ const actions = blocks.find((b) => b.type === "actions") as
+ | { elements: { text: { text: string } }[] }
+ | undefined;
+ expect(actions?.elements.map((e) => e.text.text)).toEqual([
+ "Delete",
+ "Cancel",
+ ]);
+ });
+
+ it("does not label both buttons the same when the verb collides with Cancel", () => {
+ const ir = renderToIR( );
+ const { blocks } = renderSlackMessage(ir);
+
+ const actions = blocks.find((b) => b.type === "actions") as
+ | { elements: { text: { text: string } }[] }
+ | undefined;
+ const labels = actions?.elements.map((e) => e.text.text);
+
+ // "Cancel subscription" would otherwise render Cancel/Cancel, where one of
+ // the two identical buttons destroys the subscription.
+ expect(labels).toEqual(["Confirm", "Cancel"]);
+ expect(new Set(labels).size).toBe(2);
+
+ // Relabelling must not cost the destructive styling — the action is still
+ // a cancellation, whatever the button ends up reading.
+ const styled = blocks.find((b) => b.type === "actions") as
+ | { elements: { style?: string }[] }
+ | undefined;
+ expect(styled?.elements[0]?.style).toBe("danger");
+ });
+
+ it("styles a destructive action's confirm button as dangerous", () => {
+ const ir = renderToIR( );
+ const { blocks } = renderSlackMessage(ir);
+
+ const actions = blocks.find((b) => b.type === "actions") as
+ | { elements: { style?: string }[] }
+ | undefined;
+ // The destructive button carries the warning colour; Cancel becomes the
+ // neutral escape hatch rather than the red one.
+ expect(actions?.elements[0]?.style).toBe("danger");
+ expect(actions?.elements[1]?.style).toBeUndefined();
+ });
+
+ it("names the derived verb in the lock context", () => {
+ const ir = renderToIR( );
+ const { blocks } = renderSlackMessage(ir);
+
+ const context = blocks.find((b) => b.type === "context") as
+ | { elements: { text: string }[] }
+ | undefined;
+ expect(context?.elements[0]?.text).toContain("*Delete*");
+ expect(context?.elements[0]?.text).not.toContain("Create");
+ });
+
+ it("renders fields as a headerless Slack table", () => {
+ const ir = renderToIR(
+ ,
+ );
+ const { blocks } = renderSlackMessage(ir);
+
+ const table = blocks.find((b) => b.type === "table") as
+ | { rows: { text: string }[][]; column_settings?: unknown }
+ | undefined;
+ expect(table).toBeDefined();
+
+ // No `columns` prop, so no header row is emitted — the first row is data.
+ expect(table?.column_settings).toBeUndefined();
+ expect(table?.rows.map((row) => row.map((cell) => cell.text))).toEqual([
+ ["Name", "OpenTag"],
+ ["Description", "Project for OpenTag work."],
+ ]);
+ });
+
+ it("prefers the fields table over a legacy detail string", () => {
+ const ir = renderToIR(
+ ,
+ );
+ const { blocks } = renderSlackMessage(ir);
+
+ expect(blocks.some((b) => b.type === "table")).toBe(true);
+ expect(JSON.stringify(blocks)).not.toContain('{\\"name\\"');
+ });
+
+ it("falls back to the detail section when fields is empty", () => {
+ const ir = renderToIR(
+ ,
+ );
+ const { blocks } = renderSlackMessage(ir);
+
+ expect(blocks.some((b) => b.type === "table")).toBe(false);
+ const section = blocks.find((b) => b.type === "section") as
+ | { text: { text: string } }
+ | undefined;
+ expect(section?.text.text).toContain("CPK-9");
+ });
+
+ it("renders fields as a Teams Adaptive Card table without a header row", () => {
+ const card = renderAdaptiveCard(
+ renderToIR(
+ ,
+ ),
+ );
+
+ const table = (
+ card.body as { type: string; firstRowAsHeader?: boolean }[]
+ ).find((el) => el.type === "Table");
+ expect(table).toBeDefined();
+ expect(table?.firstRowAsHeader).toBe(false);
+ expect(JSON.stringify(card)).toContain("OpenTag");
+ });
+
it("renders Create and Cancel actions as a Teams Adaptive Card", () => {
const card = renderAdaptiveCard(
renderToIR(
diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx
index 065b249e..f5f5cd5c 100644
--- a/app/human-in-the-loop/confirm-write.tsx
+++ b/app/human-in-the-loop/confirm-write.tsx
@@ -20,13 +20,31 @@ import {
Context,
Actions,
Button,
+ Table,
+ Row,
+ Cell,
} from "@copilotkit/channels";
import type { InteractionContext } from "@copilotkit/channels";
+/** One argument of the pending write, already labelled and stringified. */
+export interface ConfirmWriteField {
+ label: string;
+ value: string;
+}
+
interface ConfirmWriteProps {
/** Short imperative title of the write, e.g. 'Create Linear issue'. */
action: string;
- /** The specifics being approved — issue title + one-line description, etc. */
+ /**
+ * The write's arguments as approver-readable rows, rendered as a table. The
+ * agent decides which fields are worth showing (see `summarize_args`); this
+ * component only decides how they look.
+ */
+ fields?: ConfirmWriteField[];
+ /**
+ * Legacy single-string summary. Superseded by `fields`, but still rendered
+ * when an agent revision predating `fields` is what sent the interrupt.
+ */
detail?: string;
}
@@ -59,16 +77,80 @@ async function resumeOrShowFailure(
}
}
-export function ConfirmWrite({ action, detail }: ConfirmWriteProps) {
+/**
+ * The write's arguments, as a headerless two-column table. No `columns` prop is
+ * passed, so neither renderer emits a header row — "Field | Value" would spend a
+ * row restating what the layout already says. Slack renders cells as `raw_text`,
+ * so labels cannot be bolded here.
+ */
+function fieldTable(fields: ConfirmWriteField[]) {
+ return (
+
+ {fields.map((field) => (
+
+ | {field.label} |
+ {field.value} |
+
+ ))}
+
+ );
+}
+
+/**
+ * Verbs whose confirmation must not look inviting. A destructive action gets the
+ * warning colour on its confirm button, and Cancel drops to neutral so the red
+ * on the card marks the irreversible choice rather than the safe one.
+ */
+const DESTRUCTIVE = new Set(["delete", "remove", "archive", "cancel", "revoke"]);
+
+/** The label of the button that declines the write. */
+const DECLINE_LABEL = "Cancel";
+
+/** The action's own leading verb, e.g. `Delete customer` -> `Delete`. */
+function verbOf(action: string): string {
+ const word = action.trim().split(/\s+/)[0] ?? "";
+ if (!word) return "";
+ return word[0]!.toUpperCase() + word.slice(1);
+}
+
+/**
+ * How the confirm button reads. Normally the action's own verb, so the button
+ * never claims a delete is a create.
+ *
+ * Falls back to `Confirm` when the verb would collide with the decline button:
+ * `Cancel subscription` otherwise renders two buttons both reading "Cancel",
+ * one cancelling the subscription and one cancelling the request. Derived
+ * separately from `destructive` below, so relabelling never costs the styling.
+ */
+function confirmLabel(verb: string): string {
+ if (!verb) return "Confirm";
+ return verb.toLowerCase() === DECLINE_LABEL.toLowerCase() ? "Confirm" : verb;
+}
+
+export function ConfirmWrite({ action, fields, detail }: ConfirmWriteProps) {
+ const body = fields?.length
+ ? fieldTable(fields)
+ : detail
+ ?
+ : null;
+
+ const verb = verbOf(action);
+ const label = confirmLabel(verb);
+ // Read from the action's real verb, never from `label` — a relabelled
+ // destructive action is still destructive.
+ const destructive = DESTRUCTIVE.has(verb.toLowerCase());
+
return (
- {detail ? : null}
- {"🔒 Nothing is written until you click **Create**."}
+ {body}
+
+ {`🔒 Nothing is changed until you click **${label}**.`}
+
{
await thread.update(
message.ref,
@@ -85,11 +167,11 @@ export function ConfirmWrite({ action, detail }: ConfirmWriteProps) {
);
}}
>
- Create
+ {label}
{
await thread.update(
message.ref,
diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts
index 2b7ade9b..018e468e 100644
--- a/app/interrupt.test.ts
+++ b/app/interrupt.test.ts
@@ -61,6 +61,39 @@ describe("parseConfirmWriteInterrupt", () => {
);
});
+ it("parses the fields the agent sends for the confirmation table", () => {
+ const fields = [
+ { label: "Name", value: "OpenTag" },
+ { label: "Description", value: "Project for OpenTag work." },
+ ];
+
+ expect(
+ parseConfirmWriteInterrupt(
+ JSON.stringify({
+ ...realEnvelope,
+ __copilotkit_interrupt_value__: {
+ action: "confirm_write",
+ args: { action: "Save project", fields },
+ },
+ }),
+ ).args.fields,
+ ).toEqual(fields);
+ });
+
+ it("rejects fields that are not label/value pairs", () => {
+ expect(() =>
+ parseConfirmWriteInterrupt(
+ JSON.stringify({
+ ...realEnvelope,
+ __copilotkit_interrupt_value__: {
+ action: "confirm_write",
+ args: { action: "Save project", fields: [{ label: "Name" }] },
+ },
+ }),
+ ),
+ ).toThrow();
+ });
+
it("rejects malformed envelopes and other actions", () => {
expect(() =>
parseConfirmWriteInterrupt(
diff --git a/app/interrupt.ts b/app/interrupt.ts
index 25f18f09..e0e546e0 100644
--- a/app/interrupt.ts
+++ b/app/interrupt.ts
@@ -5,6 +5,11 @@ const confirmWriteInterruptSchema = z.object({
action: z.literal("confirm_write"),
args: z.object({
action: z.string().min(1),
+ /** Approver-readable rows built by the agent's `summarize_args`. */
+ fields: z
+ .array(z.object({ label: z.string(), value: z.string() }))
+ .optional(),
+ /** Legacy pre-`fields` summary; still accepted across a deploy skew. */
detail: z.string().nullish(),
}),
}),
diff --git a/docs/superpowers/specs/2026-08-03-confirm-write-table-design.md b/docs/superpowers/specs/2026-08-03-confirm-write-table-design.md
new file mode 100644
index 00000000..e04fce5b
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-confirm-write-table-design.md
@@ -0,0 +1,167 @@
+# confirm_write: render args as a table, not raw JSON
+
+**Date:** 2026-08-03
+**Ticket:** [CPK-7748](https://linear.app/copilotkit/issue/CPK-7748/confirm-write-card-dumps-raw-json-args-instead-of-a-readable-summary)
+**Status:** Approved
+
+## Problem
+
+The `confirm_write` approval card renders the mutating tool's arguments as a raw
+`json.dumps` string. For a Linear project write the card body is a single ~15-field
+JSON line, long enough that Slack collapses it behind **Show more** — so the approver
+cannot see the full payload at the moment they authorize the write. That defeats the
+purpose of the human-in-the-loop gate.
+
+The two fields that matter (`name`, `description`) sit mid-blob among empty defaults:
+`"color": ""`, `"id": ""`, and six empty arrays.
+
+This also contradicts the component's own contract. `ConfirmWrite`'s `detail` prop is
+documented as "the specifics being approved — issue title + one-line description",
+so the card was built for a summary and is being handed a dump.
+
+Fixing the non-functional Create button is tracked separately and owned by Tyler.
+This design covers presentation only.
+
+## Constraints discovered
+
+`@copilotkit/channels` exports `Table` / `Row` / `Cell`, and both renderers support
+them natively:
+
+| Surface | Output | Column cap | Row cap | Cell cap |
+| --- | --- | --- | --- | --- |
+| Slack | Block Kit `table` block | 20 | 100 | 2000 |
+| Teams | Adaptive Card `Table` (+ GFM pipe-table markdown fallback) | 12 | 100 | — |
+
+Two behaviors shape the design:
+
+- **Slack table cells are `raw_text`.** No bold, no links inside a cell. Bold labels
+ would require `Fields` / `Field label=` instead, which renders as a two-up grid
+ rather than aligned rows.
+- **`firstRowAsHeader` is conditional on the `columns` prop** on Teams, matching
+ Slack's behavior of only emitting a header row when `columns` is supplied. So
+ omitting `columns` yields a headerless table consistently on both surfaces.
+
+## Design
+
+The seam: **Python decides what to show, TypeScript decides how to show it.** Each
+side is independently testable, and the card stays presentational.
+
+### 1. `agent/write_confirmation.py`
+
+Replace the `json.dumps(request.args)` call with a module-level
+`summarize_args(args) -> list[dict]` helper emitting `[{"label", "value"}]`.
+
+**Omission policy.** Skip values that are `None`, `""`, `[]`, or `{}`. This is an
+explicit membership test, **not** Python falsiness — `0` and `False` must survive.
+`priority: 0` is a real Linear value ("No priority"), and dropping numeric zero is a
+recurring class of bug.
+
+**Key humanizing.** `addTeams` → "Add teams". Split camelCase, replace `_` and `-`
+with spaces, capitalize the first letter — reusing the idiom already applied to
+`action` in the same function.
+
+**Value stringifying.** Lists join with `", "`; bools become Yes/No; dicts and other
+nested values become compact JSON; everything else is `str()`. Each value then
+truncates to 300 characters with an ellipsis.
+
+**Row cap.** 12 rows. When more remain, a 13th entry is appended as
+`{"label": "…", "value": "N more fields"}` so the approver is told something was
+withheld rather than silently seeing a partial list. This mirrors the existing `MAX` +
+footer pattern in `app/components/page-list.tsx`. Well under both platform row caps;
+the cap exists for readability, not protocol limits.
+
+The interrupt payload becomes `args={"action": ..., "fields": [...]}`.
+
+### 2. `app/interrupt.ts`
+
+Add `fields: z.array(z.object({ label: z.string(), value: z.string() })).optional()`
+to the schema, and **keep `detail` accepted**.
+
+Keeping `detail` costs three lines and covers the window where a stale agent revision
+is still sending the old shape. Without it, a version skew renders a card with no body
+at all.
+
+### 3. `app/human-in-the-loop/confirm-write.tsx`
+
+Props become `{ action: string; fields?: Field[]; detail?: string }`.
+
+Render precedence:
+
+1. `fields` non-empty → a headerless `` of
+ `| {label} | {value} |
`.
+2. `detail` present → the existing `` (legacy path).
+3. Neither → no body block, as today.
+
+No `columns` prop: labels render unbold, which is accepted in exchange for dropping a
+redundant "Field | Value" header row.
+
+### 4. `app/channel.tsx`
+
+The interrupt handler passes `fields={args.fields}` alongside the existing `action`
+and `detail`.
+
+### 5. Verb-correct buttons and lock text (added after review)
+
+Originally deferred, pulled in once the table was checked against a delete. Deletes
+go through the same interceptor, so a `delete_customer` call rendered
+`🗑 Delete customer?` with a green **Create** button reading "Nothing is written
+until you click Create" — inviting styling and false wording on an irreversible
+operation.
+
+- The confirm button and lock line take the action's own leading verb: `Delete
+ customer` -> **Delete**, `Archive project` -> **Archive**.
+- "Nothing is **written**" becomes "Nothing is **changed**", which is true of
+ deletes and updates as well as creates.
+- Destructive verbs (`delete`, `remove`, `archive`, `cancel`, `revoke`) put the
+ warning colour on the confirm button and drop Cancel to neutral, so the red on
+ the card marks the irreversible choice rather than the safe one.
+- `Cancel subscription` would derive the label "Cancel" and render two identical
+ buttons, one cancelling the subscription and one cancelling the request. That
+ case falls back to **Confirm**. The destructive flag is read from the action's
+ real verb, not the substituted label, so relabelling never costs the styling.
+
+## Testing
+
+Tests are written before the implementation.
+
+**Python — `agent/tests/test_write_confirmation.py`**
+
+- empty string, empty list, empty dict, and `None` values are omitted
+- `0` and `False` are retained
+- camelCase and snake_case keys humanize correctly
+- lists join, nested values become compact JSON
+- an over-long value truncates
+- more than 12 fields produces a capped list plus an overflow note
+- the two existing envelope assertions update from `detail` to `fields`
+
+**TypeScript — `app/human-in-the-loop/__tests__/confirm-write.test.tsx`**
+
+- `fields` renders a Slack `table` block whose rows match the input, with no header row
+- `detail` alone still renders a `section` (legacy path)
+- neither prop renders no body block
+- the Teams Adaptive Card contains a `Table` with `firstRowAsHeader: false`
+- existing button, resume, and failure-path tests continue to pass unchanged
+
+## Out of scope
+
+- **The non-functional Create button.** Owned by Tyler, tracked separately.
+- **Retaining the full payload for auditability.** Considered and rejected: appending
+ the raw JSON would reintroduce the truncation this change exists to remove.
+- **Resolving opaque identifiers to names.** A delete typically carries only
+ `{"id": "3f34e790-…"}`, so the table honestly renders `Id | 3f34e790-…` — readable,
+ but it does not tell the approver *what* is about to be deleted. Fixing that means
+ the agent resolving the id to a human label before pausing, which is agent-side
+ work rather than card-side. Worth its own ticket.
+- **`main` being red.** Nine tests and twenty type errors fail on `main` after the
+ 0.6.0 / 1.65.0 bump, independent of this work. Tracked as CPK-7751.
+
+## Files touched
+
+| File | Change |
+| --- | --- |
+| `agent/write_confirmation.py` | `summarize_args` helper; emit `fields` |
+| `agent/tests/test_write_confirmation.py` | new helper tests; update envelope assertions |
+| `app/interrupt.ts` | accept `fields`, keep `detail` |
+| `app/human-in-the-loop/confirm-write.tsx` | render a headerless `Table` |
+| `app/human-in-the-loop/__tests__/confirm-write.test.tsx` | table and fallback rendering tests |
+| `app/channel.tsx` | thread `fields` through the interrupt handler |