diff --git a/docs/architecture.md b/docs/architecture.md
index 3463132..ea2601a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -105,10 +105,13 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:
- `app.js` — routing and boot
- `projects.js`, `sessions.js`, `search.js`, `export.js` — route handlers
+- `render/registry.js` — **tool dispatch registry** for session UI: `TOOL_USE_RENDERERS` and `TOOL_RESULT_RENDERERS` map tool name / `result_type` → render function (one module per type under `render/tool_use/` and `render/tool_result/`). Parallels backend `utils/tool_dispatch.py` (backend uses ordered predicates; frontend uses direct key lookup + fallback).
- `shared/markdown.js` — markdown + **DOMPurify** sanitization (do not render raw LLM HTML)
- `shared/state.js`, `shared/utils.js`, `shared/theme.js` — shared UI state and helpers
-No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`).
+`sessions.js` keeps workspace/session orchestration and message bubbles; tool cards delegate to `render/registry.js`.
+
+No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`), including `static/js/render/registry.test.js` for registry wiring and renderer escaping.
## Continuous integration
diff --git a/static/js/render/constants.js b/static/js/render/constants.js
new file mode 100644
index 0000000..8950299
--- /dev/null
+++ b/static/js/render/constants.js
@@ -0,0 +1,5 @@
+/**
+ * Sentinel when tool.name or result_type is missing.
+ * Used for fallback routing only — do not add UNKNOWN_DISPATCH_KEY to TOOL_*_RENDERERS.
+ */
+export const UNKNOWN_DISPATCH_KEY = 'unknown';
diff --git a/static/js/render/registry.js b/static/js/render/registry.js
new file mode 100644
index 0000000..893a8dd
--- /dev/null
+++ b/static/js/render/registry.js
@@ -0,0 +1,84 @@
+import { renderBashUse } from './tool_use/bash.js';
+import { renderReadUse } from './tool_use/read.js';
+import { renderWriteUse } from './tool_use/write.js';
+import { renderEditUse } from './tool_use/edit.js';
+import { renderGlobUse } from './tool_use/glob.js';
+import { renderGrepUse } from './tool_use/grep.js';
+import { renderTaskUse } from './tool_use/task.js';
+import { renderTodoWriteUse } from './tool_use/todo_write.js';
+import { renderAskUserQuestionUse } from './tool_use/ask_user_question.js';
+import { renderWebFetchUse } from './tool_use/web_fetch.js';
+import { renderWebSearchUse } from './tool_use/web_search.js';
+import { renderToolUseFallback } from './tool_use/fallback.js';
+import { getToolSummary } from './tool_use/summary.js';
+import { UNKNOWN_DISPATCH_KEY } from './constants.js';
+
+import { renderBashResult } from './tool_result/bash.js';
+import { renderFileReadResult } from './tool_result/file_read.js';
+import { renderFileEditResult } from './tool_result/file_edit.js';
+import { renderFileWriteResult } from './tool_result/file_write.js';
+import { renderGlobResult } from './tool_result/glob.js';
+import { renderGrepResult } from './tool_result/grep.js';
+import { renderWebSearchResult } from './tool_result/web_search.js';
+import { renderWebFetchResult } from './tool_result/web_fetch.js';
+import { renderTaskResult } from './tool_result/task.js';
+import { renderTodoWriteResult } from './tool_result/todo_write.js';
+import { renderUserInputResult } from './tool_result/user_input.js';
+import { renderPlanResult } from './tool_result/plan.js';
+import { renderToolResultFallback } from './tool_result/fallback.js';
+import { toolResultHasBody } from './tool_result/utils.js';
+
+export { getToolSummary, toolResultHasBody };
+
+export const TOOL_USE_RENDERERS = {
+ Bash: renderBashUse,
+ Read: renderReadUse,
+ Write: renderWriteUse,
+ Edit: renderEditUse,
+ Glob: renderGlobUse,
+ Grep: renderGrepUse,
+ Task: renderTaskUse,
+ TodoWrite: renderTodoWriteUse,
+ AskUserQuestion: renderAskUserQuestionUse,
+ WebFetch: renderWebFetchUse,
+ WebSearch: renderWebSearchUse,
+};
+
+export const TOOL_RESULT_RENDERERS = {
+ bash: renderBashResult,
+ file_read: renderFileReadResult,
+ file_edit: renderFileEditResult,
+ file_write: renderFileWriteResult,
+ glob: renderGlobResult,
+ grep: renderGrepResult,
+ web_search: renderWebSearchResult,
+ web_fetch: renderWebFetchResult,
+ task: renderTaskResult,
+ todo_write: renderTodoWriteResult,
+ user_input: renderUserInputResult,
+ plan: renderPlanResult,
+};
+
+function getToolUseRenderer(name) {
+ return Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, name)
+ ? TOOL_USE_RENDERERS[name]
+ : renderToolUseFallback;
+}
+
+function getToolResultRenderer(resultType) {
+ return Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType)
+ ? TOOL_RESULT_RENDERERS[resultType]
+ : renderToolResultFallback;
+}
+
+export function renderToolUse(tool) {
+ if (!tool) return '';
+ const name = tool.name || UNKNOWN_DISPATCH_KEY;
+ return getToolUseRenderer(name)(tool);
+}
+
+export function renderToolResult(parsed) {
+ if (!parsed) return '';
+ const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY;
+ return getToolResultRenderer(rt)(parsed);
+}
diff --git a/static/js/render/registry.test.js b/static/js/render/registry.test.js
new file mode 100644
index 0000000..432225e
--- /dev/null
+++ b/static/js/render/registry.test.js
@@ -0,0 +1,203 @@
+import { describe, it, expect } from 'vitest';
+import {
+ TOOL_USE_RENDERERS,
+ TOOL_RESULT_RENDERERS,
+ renderToolUse,
+ renderToolResult,
+ getToolSummary,
+ toolResultHasBody,
+} from './registry.js';
+import { UNKNOWN_DISPATCH_KEY } from './constants.js';
+import { renderWebFetchUse } from './tool_use/web_fetch.js';
+
+const CORE_TOOL_USE = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Task', 'TodoWrite', 'AskUserQuestion', 'WebFetch', 'WebSearch'];
+
+const CORE_TOOL_RESULT = [
+ 'bash',
+ 'file_read',
+ 'file_edit',
+ 'file_write',
+ 'glob',
+ 'grep',
+ 'web_search',
+ 'web_fetch',
+ 'task',
+ 'todo_write',
+ 'user_input',
+ 'plan',
+];
+
+describe('TOOL_USE_RENDERERS', () => {
+ it('registers core tool names', () => {
+ for (const name of CORE_TOOL_USE) {
+ expect(TOOL_USE_RENDERERS[name], name).toBeTypeOf('function');
+ }
+ });
+
+ it('does not register the unknown dispatch sentinel as a tool renderer', () => {
+ expect(Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, UNKNOWN_DISPATCH_KEY)).toBe(false);
+ });
+
+ it('renderBashUse escapes HTML in command', () => {
+ const html = renderToolUse({
+ name: 'Bash',
+ input: { command: '' },
+ });
+ expect(html).not.toContain('