← Home

Agent Canvas Tool Visualizers architecture note

How OpenHands/agent-canvas PR #1246 turns tool events into dedicated React cards, and how the same seam can become an addon extension point. Studied from the PR branch, June 2026.

Short version: the PR keeps the existing conversation card chrome and title pipeline, but swaps the expandable details body from markdown text to a React visualizer whenever the event kind is registered. Unknown tools still fall back to the old markdown/JSON rendering.

What changed

Before this PR, almost every tool call flowed through two large markdown builders: get-action-content.ts and get-observation-content.ts. That made a bash command, a grep result, a file edit, and an unknown task result look structurally similar, and unsupported observations could degrade into raw JSON.

The new system adds a small visualizer registry under src/components/features/chat/tool-visualizers/. Visualizers are ordinary React components, but they are registered by event kind and type-narrowed by defineVisualizer().

ExecuteBashAction TerminalAction FileEditorAction StrReplaceEditorAction GrepAction GlobAction TaskAction
PartRoleWhy it matters
define.tsTyped visualizer helperNarrows Action and Observation unions by kind, so the component body gets the right fields.
index.tsExplicit registryImports built-ins and indexes them by action/observation kind. No magic auto-discovery in core.
dispatcher.tsxRuntime resolverLooks up the event kind and returns a React node, or null to keep the markdown fallback.
primitives/*Shared UI piecesReusable CodeBlock, DiffView, OutputPane, KeyValueGrid, and path chip components.

The render path

The key design choice is that the visualizer is not a new card type. It only replaces the card body. Titles, collapse state, success dots, confirmation buttons, grouping, and the ACP path stay where they were.

flowchart TD
  A[Agent Server emits event] --> B[REST history / WebSocket stream]
  B --> C[Conversation event store]
  C --> D[Messages / EventMessage]
  D --> E{Event shape?}
  E -->|message| M[User or assistant message renderer]
  E -->|action| F[GenericEventMessageWrapper]
  E -->|observation| G[Find corresponding action by action_id]
  G --> F
  F --> H[getEventContent]
  H --> I[Existing title switch]
  H --> J[resolveVisualizerBody]
  J --> K{Registered kind?}
  K -->|yes| L[Visualizer Body React node]
  K -->|no| N[Existing markdown fallback]
  L --> O[GenericEventMessage details]
  N --> O
  I --> O
    

What happens for an action

When an action event arrives first, resolveVisualizerBody(event) checks actionVisualizers.get(event.action.kind). If present, it renders <Body action={event} />. This is why an in-flight task can already show the parent agent’s query before the subagent result exists.

What happens for an observation

When an observation arrives, EventMessage looks back through the visible events for the action whose id matches event.action_id. The dispatcher then renders <Body action={correspondingAction} observation={event} />. That gives observation cards access to action-side context such as a file editor view range or the original task prompt.

sequenceDiagram
  participant AS as Agent Server
  participant UI as Event store
  participant EM as EventMessage
  participant GC as getEventContent
  participant VR as visualizer registry
  participant Card as GenericEventMessage

  AS->>UI: ActionEvent(TaskAction)
  UI->>EM: render action
  EM->>GC: getEventContent(action)
  GC->>VR: lookup TaskAction
  VR-->>GC: taskVisualizer.Body
  GC-->>Card: title + React details(query)

  AS->>UI: ObservationEvent(TaskObservation, action_id)
  UI->>EM: render observation
  EM->>EM: find action by action_id
  EM->>GC: getEventContent(observation, action)
  GC->>VR: lookup TaskObservation
  VR-->>GC: taskVisualizer.Body
  GC-->>Card: title + React details(query + result)
    

Per-tool examples

Bash / terminal

Registered for ExecuteBashAction, TerminalAction, ExecuteBashObservation, and TerminalObservation.

  • Shows the command in a copyable bash code block.
  • Shows medium/high security risk warning on the action card.
  • Shows copyable output plus exit-code status on the observation card.

File editor

Registered for both the old and new file-editor action/observation names.

  • Displays a path chip with optional line range.
  • Uses a code block for view and create.
  • Uses a diff for str_replace and insert.

Search

Registered for GrepAction, GlobAction, GrepObservation, and GlobObservation.

  • Normalizes pattern, path, and include fields into a key/value grid.
  • Shows count, result list, empty state, truncation, or error state.

Task / subagent

Registered for TaskAction and TaskObservation.

  • Shows subagent type and task id.
  • Shows the parent agent’s prompt as the task query.
  • Renders the subagent result as markdown with copy affordance.

Why it is incremental

The fallback remains the safety net. A visualizer can be added for one action/observation pair without migrating every tool. That is important for unsupported or newly introduced SDK tools: the UI may be less pretty, but it still renders the event.

flowchart LR
  A[OpenHandsEvent] --> B{Known action/observation kind?}
  B -->|Registered visualizer| C[React visualizer body]
  B -->|No visualizer| D[Markdown helper]
  D --> E{Known markdown switch case?}
  E -->|yes| F[Readable markdown]
  E -->|no| G[JSON fallback]
  C --> H[Same collapsible card]
  F --> H
  G --> H
    

Important boundary: InvokeSkillObservation is not a normal visualizer in this PR. It now reuses the existing SkillReadyContentList under an “Invoked Skill Knowledge” heading, so skill knowledge shares the same expandable list UI as the skill-ready event.

How a developer adds one today

Today, defining a different renderer requires editing the Agent Canvas source (or a fork). The in-tree recipe is intentionally small:

  1. Create src/components/features/chat/tool-visualizers/<name>/<name>.tsx.
  2. Export defineVisualizer({ actionKinds, observationKinds, Body }).
  3. Import it in tool-visualizers/index.ts and add it to the ALL array.
  4. Add a focused render test and snapshots for action, observation, and error/empty states.
export const myToolVisualizer = defineVisualizer({
  actionKinds: ["MyToolAction"],
  observationKinds: ["MyToolObservation"],
  Body: function MyToolBody({ action, observation }) {
    return observation
      ? <MyResult event={observation} sourceAction={action} />
      : <MyInFlight event={action} />;
  },
});

Current limitation: this is not yet a user-level extension mechanism. It is a clean internal seam, but a user cannot drop a visualizer into ~/.openhands/agent-canvas and have the installed UI pick it up without a follow-up addon architecture.

Follow-up: custom visualizers as addons

Issue #481 proposes trusted local Agent Canvas UI addons. The visualizer registry from this PR is a good first real use case for route-less JavaScript addons: instead of only adding pages or CSS, an addon could register event renderers for the conversation stream.

flowchart TD
  A[~/.openhands/agent-canvas/addons/*] --> B[addon manifest]
  B --> C[generated addon registry for dev/build]
  C --> D[Addon register(api)]
  D --> E[api.registerToolVisualizer]
  E --> F[Runtime visualizer registry]
  F --> G{priority}
  G -->|addon override| H[Addon visualizer]
  G -->|built-in| I[Core visualizer]
  G -->|none| J[Markdown fallback]
    

Minimal API shape

The smallest useful follow-up is an addon API method that composes with the existing registry:

export default function register(api: AddonApi) {
  api.registerToolVisualizer({
    id: "acme.kubernetes.apply",
    actionKinds: ["MCPToolAction"],
    observationKinds: ["MCPToolObservation"],
    priority: 50,
    matches({ action, observation }) {
      return action?.action.kind === "MCPToolAction"
        && action.action.tool_name === "kubectl_apply";
    },
    Body({ action, observation }) {
      return <KubernetesApplyCard action={action} observation={observation} />;
    },
  });
}
Design pointRecommendation
RegistrationAdd api.registerToolVisualizer() and build the active registry as addon visualizers + built-ins.
OverridesSupport priority and matches(). This lets a custom MCP tool visualizer handle only one tool name while the generic MCP visualizer handles the rest.
PackagingStart with trusted local source addons compiled by Vite in dev/source mode, matching Devin’s proposal. Later add a compiled-runtime path for installed/static packages.
LocationPrefer ~/.openhands/agent-canvas/addons/<id> so user customizations do not dirty the Agent Canvas checkout.
SafetyTreat addons as local trusted code, show them in settings, allow disable/remove, wrap each renderer in an error boundary, and fall back to the next renderer or markdown on failure.
Stable surfaceExport visualizer types and primitives from a public subpath such as @openhands/agent-canvas/visualizers.

Why this fits the addon discussion

Devin’s issue asks whether route-less JavaScript should be root-mounted React, compiled runtime scripts, explicit shell slots, or deferred. Custom event rendering is narrower than a root-level addon and more concrete than a generic shell slot. It can be modeled as a named slot:

slots: {
  "conversation.event.visualizers": [myVisualizer]
}

That aligns with the slot proposal in the issue comments while preserving the visualizer PR’s fallback behavior. Addons can replace or extend rendering for selected event kinds, but the core conversation renderer still owns ordering, grouping, titles, collapse behavior, and the default safety net.

Open questions for the follow-up

Proposed next step: land the in-tree visualizer PR as the core seam, then open a focused addon follow-up that exposes only registerToolVisualizer() or a conversation.event.visualizers slot. This gives users a real customization path without needing the full extension platform to be solved first.

Hermes Plugin API →  ·  PR #1246 →  ·  Addon RFC #481 →  ·  ← Home