Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Build an interactive Harn app

This guide builds a stateful app without app-specific JavaScript or Rust. The complete small example is examples/apps/decision-card.harn.

1. Declare a shared-renderer resource

import * as ui from "std/ui"
import { UiElement } from "std/ui/contracts"

const resource = ui.app_resource(
  "ui://example/decision-card",
  "Decision Card",
  "decision.handle_event",
)

ui.app_resource packages the shared renderer as a standard text/html;profile=mcp-app resource. The third argument names the one Harn tool that receives UI events.

2. Render state as a document

fn render(choice: string, revision: int) {
  const elements: list<UiElement> = [
    {id: "card", kind: "column", variant: "panel"},
    {
      id: "choice",
      kind: "select",
      parent: "card",
      label: "Decision",
      value: choice,
      options: [
        {value: "approve", label: "Approve"},
        {value: "revise", label: "Request changes"},
      ],
    },
  ]
  return ui.update(ui.document("Decision Card", revision, elements))
}

Parents must be rows or columns and appear before their children. IDs are unique and stable across renders. ui.document rejects invalid IDs and parent links before they reach a browser.

3. Handle typed events

fn handle_event(raw) {
  const event = ui.event(raw.event)
  if event.kind == "input" && event.target == "choice" {
    choice = event.value ?? "approve"
  }
  revision = revision + 1
  return render(choice, revision)
}

Treat raw as untrusted. ui.event checks the schema, event kind, target, and canvas coordinates from 0.0 to 1.0 once at the boundary.

4. Register the tool and resource

Give the tool an object return schema. Harn then sends the UiUpdate as MCP structuredContent, which avoids parsing presentation text.

tools = tool_define(tools, "decision.handle_event", "Apply one UI event", {
  parameters: {event: {type: "object", required: true}},
  returns: {type: "object"},
  handler: handle_event,
  meta: ui.tool_metadata(resource, {visibility: ["app"]}),
})
harness.tools.mcp_tools(tools)
harness.tools.mcp_resource(ui.mcp_resource(resource))

Use visibility: ["app"] when the model does not need to call the event tool.

5. Run and test

harn app run examples/apps/decision-card.harn

Test the Harn event handler in process with ui.test.run. It follows scheduled send_event effects immediately, so polling and recovery tests do not use real sleeps. Use the standalone host for the final pointer, focus, accessibility, and screenshot checks.

For a larger app that restores drawings after restart, runs local and hosted image models, cancels work, writes result files, and replays recorded output, run:

harn app run examples/apps/logo-studio.harn

The replay model is offline. Local editing uploads the verified canvas capture to the ComfyUI server named by COMFYUI_URL, then runs FLUX.2 Klein against that reference image. Hosted editing expects OPENAI_API_KEY in the process environment. The app paints its working state before sending the hosted call. Unlike the queued ComfyUI path, that hosted request cannot be canceled after it has been sent.

The local path below used a mouse sketch, two directions, and FLUX.2 Klein on an RTX 5090. The app uploaded the verified canvas capture, showed the returned image, and wrote the same content-addressed PNG to the selected path.

Logo Studio after a local FLUX.2 image edit

6. Check interaction and recovery

Use stable element IDs so focus, tests, and host actions address the same control after each update. Give every field a label, use heading levels in order, give images useful alternative text, and provide buttons for canvas actions that cannot be done with a keyboard.

Keep the last successful result visible when a new model call fails. Show the failure in the app status, leave the retry action available, and disable any setting that would change the backend of a running job. If cancellation fails, keep tracking the job and try another status check.

Before writing a result file, create its parent directory and turn file errors into app status instead of a broken tool call. Restart the host during the manual test and confirm that strokes, directions, the selected model, a running job, and the last verified image reopen from the app's work record.

The output field accepts a path relative to the app's working directory or an absolute path allowed by the host's file policy. Keep that policy narrow when embedding an app that should only write inside one project or export folder.

Check these edges before shipping:

  • a click without pointer movement still creates a visible canvas dot;
  • one stroke never sends more than 4,096 points;
  • invalid element IDs, parent links, heading levels, canvas sizes, stroke widths, and non-finite coordinates fail before rendering;
  • an unreachable provider returns a typed model-job error and a later retry can succeed;
  • data-image previews load under the host and resource security policies;
  • the write button reports an unwritable path without losing the generated asset.