> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hiroleague.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Channel Plugins

> The subprocess components that connect Hiro Server to devices and external communication surfaces.

export const DiagramViewer = ({children, title = "Diagram"}) => {
  const dialogRef = useRef(null);
  const viewportRef = useRef(null);
  const contentRef = useRef(null);
  const [isOpen, setIsOpen] = useState(false);
  const [zoom, setZoom] = useState(1);
  const [pan, setPan] = useState({
    x: 0,
    y: 0
  });
  const zoomRef = useRef(1);
  const panRef = useRef({
    x: 0,
    y: 0
  });
  const dragRef = useRef(null);
  const open = () => {
    setZoom(1);
    setPan({
      x: 0,
      y: 0
    });
    setIsOpen(true);
  };
  const close = () => {
    setZoom(1);
    setPan({
      x: 0,
      y: 0
    });
    setIsOpen(false);
  };
  const resetView = () => {
    fitDiagramToViewport();
    setPan({
      x: 0,
      y: 0
    });
  };
  const setView = (nextZoom, nextPan) => {
    zoomRef.current = nextZoom;
    panRef.current = nextPan;
    setZoom(nextZoom);
    setPan(nextPan);
  };
  const MaximizeIcon = () => <svg aria-hidden="true" className="diagram-viewer__icon" viewBox="0 0 24 24">
      <path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" />
    </svg>;
  const ResetIcon = () => <svg aria-hidden="true" className="diagram-viewer__icon" viewBox="0 0 24 24">
      <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
      <path d="M3 3v5h5" />
    </svg>;
  const CloseIcon = () => <svg aria-hidden="true" className="diagram-viewer__icon" viewBox="0 0 24 24">
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>;
  const fitDiagramToViewport = () => {
    const viewport = viewportRef.current;
    const content = contentRef.current;
    const svg = content?.querySelector(".mermaid svg[viewBox]");
    if (!viewport || !svg) return;
    const viewBox = svg.getAttribute("viewBox")?.split(/\s+/).map(Number);
    if (!viewBox || viewBox.length !== 4 || !viewBox[2] || !viewBox[3]) return;
    const [, , width, height] = viewBox;
    svg.style.width = `${width}px`;
    svg.style.maxWidth = "none";
    const rect = viewport.getBoundingClientRect();
    const nextZoom = Math.min(2.2, Math.max(0.75, Number((Math.min((rect.width - 96) / width, (rect.height - 96) / height) * 0.98).toFixed(3))));
    setView(nextZoom, {
      x: 0,
      y: 0
    });
  };
  const handleWheel = event => {
    if (!isOpen) return;
    event.preventDefault();
    event.stopPropagation();
    const viewport = viewportRef.current;
    if (!viewport) return;
    const rect = viewport.getBoundingClientRect();
    const pointerX = event.clientX - rect.left - rect.width / 2;
    const pointerY = event.clientY - rect.top - rect.height / 2;
    const currentZoom = zoomRef.current;
    const currentPan = panRef.current;
    const nextZoom = Math.max(0.25, Math.min(5, Number((currentZoom * (event.deltaY < 0 ? 1.12 : 0.88)).toFixed(3))));
    const ratio = nextZoom / currentZoom;
    setView(nextZoom, {
      x: pointerX - (pointerX - currentPan.x) * ratio,
      y: pointerY - (pointerY - currentPan.y) * ratio
    });
  };
  const onPointerDown = event => {
    if (!isOpen || event.button !== 0) return;
    event.preventDefault();
    dragRef.current = {
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      pan: panRef.current
    };
    try {
      event.currentTarget.setPointerCapture?.(event.pointerId);
    } catch {}
  };
  const onPointerMove = event => {
    const drag = dragRef.current;
    if (!isOpen || !drag || drag.pointerId !== event.pointerId) return;
    event.preventDefault();
    const nextPan = {
      x: drag.pan.x + event.clientX - drag.startX,
      y: drag.pan.y + event.clientY - drag.startY
    };
    panRef.current = nextPan;
    setPan(nextPan);
  };
  const endDrag = event => {
    if (dragRef.current?.pointerId === event.pointerId) {
      dragRef.current = null;
    }
  };
  useEffect(() => {
    const dialog = dialogRef.current;
    if (isOpen && dialog && !dialog.open) {
      dialog.showModal();
      requestAnimationFrame(() => {
        requestAnimationFrame(fitDiagramToViewport);
      });
    }
    if (!isOpen && dialog?.open) {
      dialog.close();
    }
  }, [isOpen]);
  useEffect(() => {
    if (!isOpen) return;
    const onKeyDown = event => {
      if (event.key === "Escape") close();
    };
    const onWheel = event => {
      event.preventDefault();
      event.stopPropagation();
      if (viewportRef.current?.contains(event.target)) {
        handleWheel(event);
      }
    };
    const previousOverflow = document.body.style.overflow;
    const previousHtmlOverflow = document.documentElement.style.overflow;
    document.body.style.overflow = "hidden";
    document.documentElement.style.overflow = "hidden";
    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("wheel", onWheel, {
      capture: true,
      passive: false
    });
    return () => {
      document.body.style.overflow = previousOverflow;
      document.documentElement.style.overflow = previousHtmlOverflow;
      window.removeEventListener("keydown", onKeyDown);
      window.removeEventListener("wheel", onWheel, {
        capture: true
      });
    };
  }, [isOpen]);
  const toolbar = <div className="diagram-viewer__bar">
      <span className="diagram-viewer__title">{title}</span>
      <div className="diagram-viewer__actions">
        {isOpen && <span className="diagram-viewer__zoom" aria-live="polite">
            {Math.round(zoom * 100)}%
          </span>}
        {isOpen && <button className="diagram-viewer__button" type="button" onClick={resetView} aria-label="Reset diagram">
            <ResetIcon />
          </button>}
        <button className="diagram-viewer__button" type="button" onClick={isOpen ? close : open} aria-label={isOpen ? "Close" : "Maximize"}>
          {isOpen ? <CloseIcon /> : <MaximizeIcon />}
        </button>
      </div>
    </div>;
  const diagram = <div ref={viewportRef} className="diagram-viewer__viewport" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
      <div className="diagram-viewer__stage" style={{
    transform: isOpen ? `translate(${pan.x}px, ${pan.y}px)` : undefined
  }}>
        <div className="diagram-viewer__centerer">
          <div ref={contentRef} className="diagram-viewer__content" style={{
    transform: isOpen ? `scale(${zoom})` : "none"
  }}>
            {children}
          </div>
        </div>
      </div>
    </div>;
  return <>
      {!isOpen && <div className="diagram-viewer not-prose">
          {toolbar}
          {diagram}
        </div>}

      <dialog ref={dialogRef} className="diagram-viewer-dialog" onWheel={event => {
    if (event.target === event.currentTarget) {
      event.preventDefault();
      event.stopPropagation();
    }
  }} onCancel={event => {
    event.preventDefault();
    close();
  }} onClose={() => setIsOpen(false)}>
        {isOpen && <div className="diagram-viewer diagram-viewer--open not-prose">
            {toolbar}
            {diagram}
          </div>}
      </dialog>
    </>;
};

Channel plugins are standalone subprocesses that adapt communication surfaces into Hiro Server's message protocol.

Channel plugins do not run inside the Hiro Server process. Channel Manager starts them, they connect back over a local WebSocket, and both sides exchange JSON-RPC 2.0 messages carrying `UnifiedMessage` payloads and channel events.

## Plugin topology

<DiagramViewer title="Channel plugin topology">
  ```mermaid actions={false} placement="top-right" theme={null}
  ---
  config:
    theme: 'base'
    themeVariables:
      primaryColor: '#eedbb1'
      secondaryColor: '#eedbb166'
      primaryTextColor: '#000'
      lineColor: '#fff'
      fontFamily: '''Merriweather Variable'', serif'

  ---

  graph LR
      CM("Communication Manager")
      UM1[/"UnifiedMessage<br/>message envelope"/]
      PM("ChannelManager<br/>:18081")
      DEV("hiro-channel-devices<br/>(built-in)")
      TG("hiro-channel-telegram")
      WA("hiro-channel-whatsapp")
      UM2[/"UnifiedMessage<br/>message envelope"/]
      Gate("Hiro Gate")
      Mobile("Hiro Mobile App")
      TelegramFormat("Telegram message<br/>format")
      Telegram("Telegram")
      WhatsAppFormat("WhatsApp message<br/>format")
      WhatsApp("WhatsApp")

      CM <--> UM1
      UM1 <--> PM
      PM <-->|"local WS<br/>JSON-RPC 2.0"| DEV
      PM <-->|"local WS<br/>JSON-RPC 2.0"| TG
      PM <-->|"local WS<br/>JSON-RPC 2.0"| WA

      DEV <--> UM2
      UM2 <--> Gate
      Gate <--> Mobile
      TG <--> TelegramFormat
      TelegramFormat <--> Telegram
      WA <--> WhatsAppFormat
      WhatsAppFormat <--> WhatsApp

      UM1@{ shape: card}
      UM2@{ shape: card}
      TelegramFormat@{ shape: card}
      WhatsAppFormat@{ shape: card}

      style CM fill:#029E75AA
      style Gate fill:#029E75AA
      style Mobile fill:#029E75AA
      style DEV fill:#029E75, stroke:#F65107, stroke-width:4px
      style TG  fill:#029E75, stroke:#F65107, stroke-width:4px
      style WA  fill:#029E75, stroke:#F65107, stroke-width:4px

  ```
</DiagramViewer>

The plugin owns translation between its external protocol and `UnifiedMessage`. Hiro Server owns workspace policy, routing, persistence, and agent execution.

## Responsibilities

**External protocol adaptation** - The plugin translates between its external surface and Hiro's `UnifiedMessage` format. For the built-in `devices` plugin, the external surface is Hiro Gate. For optional plugins, it can be a third-party messaging platform.

**Connection to Channel Manager** - The plugin process connects to Channel Manager using the WebSocket URL passed at launch. It registers its name, version, and description before it starts normal work.

**Inbound emission** - When the external surface produces a user message, the plugin validates or normalizes it into a `UnifiedMessage` and emits it to Channel Manager as `channel.receive`.

**Outbound dispatch** - When Hiro Server sends a message through this channel, the plugin receives `channel.send` and translates the `UnifiedMessage` into the external protocol's send operation.

**Channel events** - The plugin emits `channel.event` for transport and infrastructure state that is not itself a user message, such as gateway connection state, device connection state, or pairing requests.

**Lifecycle handling** - The plugin receives configuration after registration, starts its external listeners, handles reconnects where applicable, and stops cleanly when Channel Manager sends `channel.stop`.

## Runtime lifecycle

<DiagramViewer title="Channel plugin lifecycle">
  ```mermaid actions={false} theme={null}
  sequenceDiagram
      participant CM as Channel Manager
      participant Proc as Plugin process
      participant T as PluginTransport
      participant P as Channel Plugin
      participant X as External surface

      CM->>Proc: Spawn subprocess
      Proc->>T: Start entry point
      T->>CM: Connect to local WebSocket
      T->>CM: channel.register
      CM->>T: channel.configure
      T->>P: on_configure(config)
      T->>P: on_start()
      P->>X: Start external listener
      X-->>P: Messages and events
      CM->>T: channel.stop
      T->>P: on_stop()
      Proc-->>CM: Process exits
  ```
</DiagramViewer>

Channel Manager appends runtime arguments when it starts a plugin: the local Hiro WebSocket URL, log directory, and log level. The plugin's entry point uses those values to initialize logging and create `PluginTransport`.

## JSON-RPC surface

All plugin-to-server traffic uses JSON-RPC 2.0 over one local WebSocket connection.

<DiagramViewer title="Channel plugin JSON-RPC">
  ```mermaid actions={false} theme={null}
  sequenceDiagram
      participant CM as Channel Manager
      participant T as PluginTransport
      participant P as Channel Plugin
      participant X as External surface

      T->>CM: channel.register
      CM->>T: channel.configure
      T->>P: on_configure(config)
      T->>P: on_start()

      X->>P: External message
      P->>T: emit(UnifiedMessage)
      T->>CM: channel.receive

      X->>P: External infrastructure event
      P->>T: emit_event(event, data)
      T->>CM: channel.event

      CM->>T: channel.send
      T->>P: send(UnifiedMessage)
      P->>X: External send

      CM->>T: channel.event
      T->>P: on_event(event, data)

      CM->>T: channel.status
      T-->>CM: status response

      CM->>T: channel.stop
      T->>P: on_stop()
  ```
</DiagramViewer>

### Plugin to Channel Manager

| Method             | Purpose                                                                                       |
| ------------------ | --------------------------------------------------------------------------------------------- |
| `channel.register` | Announces the plugin connection and metadata. This is the first required frame after connect. |
| `channel.receive`  | Sends an inbound `UnifiedMessage` into Hiro Server.                                           |
| `channel.event`    | Sends plugin infrastructure events into Hiro Server.                                          |

### Channel Manager to plugin

| Method              | Purpose                                                                        |
| ------------------- | ------------------------------------------------------------------------------ |
| `channel.configure` | Pushes channel-specific settings and credentials from workspace configuration. |
| `channel.send`      | Sends an outbound `UnifiedMessage` through this channel.                       |
| `channel.event`     | Sends server-originated infrastructure events to the plugin.                   |
| `channel.status`    | Probes live plugin status.                                                     |
| `channel.stop`      | Requests graceful plugin shutdown.                                             |

## Persisted configuration

Channel plugin configuration is stored in the `channel_plugins` table in `workspace.db`.

| Item                | Purpose                                                                     |
| ------------------- | --------------------------------------------------------------------------- |
| Channel name        | Stable channel identifier, such as `devices`, `telegram`, or `echo`.        |
| Enabled state       | Controls whether Channel Manager spawns the plugin at server startup.       |
| Launch command      | Command used to start the plugin process.                                   |
| Workspace directory | Optional uv workspace directory used for development launches.              |
| Config payload      | Channel-specific settings pushed to the plugin through `channel.configure`. |

Runtime PID files live under `channels/<channel_name>.pid`. Plugin logs are written under `logs/channel-<name>.log`.

## Built-in `devices` plugin

The `devices` channel plugin is built in and required. It bridges Hiro Server to Hiro Gate.

| Direction              | What it translates                                                             |
| ---------------------- | ------------------------------------------------------------------------------ |
| Gateway to Hiro Server | Gateway relay frames into inbound `UnifiedMessage` payloads or channel events. |
| Hiro Server to Gateway | Outbound `UnifiedMessage` payloads into gateway relay frames.                  |
| Gateway infrastructure | Pairing requests and device connection state into `channel.event`.             |
| Server infrastructure  | Pairing responses from Hiro Server into gateway pairing response frames.       |

Optional channel plugins follow the same Channel Manager contract, but their external surface is a third-party service instead of Hiro Gate.

## Related components

### **Channel Manager**

Channel Manager owns plugin process lifecycle, WebSocket accept, JSON-RPC dispatch, and the connection registry. See [Channel Manager](/architecture/concepts/channel-manager).

### **Communication Manager**

Communication Manager receives validated `UnifiedMessage` payloads from Channel Manager and owns message routing inside Hiro Server. See [Communication Manager](/architecture/concepts/communication-manager).

### **Protocol contract**

Channel plugins exchange `UnifiedMessage` payloads with Hiro Server. See [Protocol contract](/architecture/protocol/protocol-contract) and [UnifiedMessage reference](/architecture/protocol/unified-message).

***

## See also

<CardGroup cols={2}>
  <Card title="Channel Manager" icon="plug" href="/architecture/concepts/channel-manager">
    The server-side owner of channel plugin subprocesses and JSON-RPC connections.
  </Card>

  <Card title="Creating channel plugins" icon="code" href="/build/guides/creating-channel-plugins">
    Build guide for implementing a new plugin.
  </Card>

  <Card title="UnifiedMessage reference" icon="envelope" href="/architecture/protocol/unified-message">
    The message envelope exchanged by plugins and Hiro Server.
  </Card>
</CardGroup>
