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

> ## Agent Instructions
> For agents: install the OpenWork desktop app with `brew install --cask openwork` (macOS) or from https://openworklabs.com/download. Do not run `npx openwork`; the npm package named `openwork` is a different project.
> Connect an agent to OpenWork with the MCP Gateway: `claude mcp add --transport http openwork https://api.openworklabs.com/mcp/agent`. Teams sign up at https://app.openworklabs.com?mode=sign-up.
> Step-by-step agent guide: https://openworklabs.com/docs/start-here/use-openwork-from-an-ai-agent.md
> OpenWork in Slack is private alpha for approved organizations. Setup guide: https://openworklabs.com/docs/slack/overview.md. Agent skill: https://openworklabs.com/.well-known/agent-skills/set-up-openwork-slack/SKILL.md.

# Build an MCP App

> Build an MCP App widget that OpenWork renders in chat and on dashboards, and the host protocol OpenWork supports.

[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) are a standard extension of MCP (`io.modelcontextprotocol/ui`). An app is an ordinary MCP server with a tool that points to a UI resource and that resource served as HTML. OpenWork renders it as real, interactive UI in chat and on your dashboard; what you do inside it is not sent to the model. OpenWork supports the subset below, not every optional feature of the specification. Apps must check the host's advertised capabilities. The [`ext-apps` repository](https://github.com/modelcontextprotocol/ext-apps) provides the SDK and examples.

To build an App without writing an MCP server, ask the agent for one: see [Apps you build are their own MCP servers](/docs/cloud/run-in-the-cloud/cloud-mcp).

## How OpenWork runs tools inside an App

Once a connected App is open, annotated read-only helpers can run automatically. Any tool that can modify data requires a recent, verified click inside that App. Clicking **Send**, for example, authorizes one tool call without an additional OpenWork approval modal. Native keyboard activation of a button counts; synthetic clicks and background writes do not.

The interaction grant is single-use and lasts at most **1,500 milliseconds** in the focused App. The first tool call consumes it, even if that call is read-only; a second write needs a new trusted click. Unapproved writes fail with `tool_requires_approval` before reaching the provider. Calls still require authenticated collaborator access, organization permissions, the originating server, and a live launch lease. Read-only previews and archived conversations cannot call tools, even read-only helpers.

## Supported host protocol

The shared chat and dashboard renderer uses the stable **2026-01-26** protocol with `ext-apps` **1.7.5**:

* After initialization, the host sends complete `ui/notifications/tool-input` arguments before `ui/notifications/tool-result`. Result delivery retains `content`, `structuredContent`, `_meta`, and the optional `isError` flag, including explicit `false`.
* Eligible dashboard refreshes use notification updates in that same document: ordered input/result delivery updates the data without another initialization. App result handlers must accept subsequent results rather than treating the first result as the only one.
* Writable Apps advertise `serverTools` and `openLinks`. Tool calls retain the originating endpoint, workspace, session, engine (when supplied), and live launch lease through dispatch. Automatic read-only helpers are allowed; writes require the recent single-use trusted interaction described above, without an additional host confirmation. OpenWork's sandbox proxy verifies the interaction and overwrites request `_meta["openwork/userInteraction"]`; an App cannot authorize itself by supplying that field. Link requests require an active writable view, use the host's HTTP(S)-only opener, and report failures.
* Only `inline` display is available. Valid `ui/request-display-mode` requests return the current `inline` mode, including requests for fullscreen or picture-in-picture; they do not change the layout.
* Size notifications adjust the inline height within host limits. Unmount retains the existing bounded `ui/resource-teardown` handshake and bridge close.
* Message/context writes, View-provided tools, and resource proxy requests are not supported by this renderer. Unsupported requests receive protocol errors, not successful empty responses. There is no full progress or cancellation forwarding.

| Host surface | Tools and links | Ownership |
| - | - | - |
| Active conversation, including a split pane | Advertised for writable views with a live lease | The message list supplies its own client, workspace and session, not the currently selected root workspace. |
| Live dashboard tile | Advertised while its launch remains active | The exact endpoint and workspace that resolved the tile own its lease; there is no conversation session. |
| Archived conversation | Neither advertised nor allowed | The original conversation supplies a read-only origin. |
| Cached dashboard preview | Neither advertised nor allowed until a fresh launch succeeds | Cached data never persists a live lease; Run obtains a new one. |
| Generated result preview | Neither advertised nor allowed | Explicit read-only origin without a conversation session. |

Launch leases expire **30 minutes after creation**; tool calls and clicks do not extend that fixed lifetime. This lease is separate from the 1,500-millisecond interaction grant. Closing or replacing a view disables its pending actions and releases its owned lease. Dashboard endpoint removal also retires that endpoint's lease. A restored cached view cannot reuse the previous lease, and the server revalidates launch ownership before dispatching tools. Reopen or freshly launch the App after its lease expires.

In-place refresh requires a valid, eligible read-only dashboard launch with server-advertised `app.refresh` metadata containing `resourceDigest` and `expiresAt`. The host submits the expected resource digest; the server checks the live lease, permissions, tool and current resource before dispatch. A changed resource is denied before the tool runs, and the host replaces the old view through the normal guarded launch path instead of delivering incompatible data to it. A changed launch scope, input or policy also invalidates document reuse; any permitted fresh launch creates a replacement document. Refresh does not extend the fixed lease, authorize a write, or relax authentication, approval or retry rules. This continuity behavior does not add automatic recovery from expired-launch errors.

Chat views mount from completed, preserved results; dashboard tiles render successful or saved results. A cancelled in-flight launch therefore has no mounted recipient for `ui/notifications/tool-cancelled`. Cancelling another operation or unmounting an already completed result does not invent a cancellation notification. Early mounting and streaming launch input are not implemented. Dashboard launch failures retain the existing error or saved-result presentation rather than mounting an actionable failed launch.

## Provider data and gateway additions

Provider `structuredContent` is left unchanged, including fields named `serverTools` or `schemaGuidance`. OpenWork gateway additions use reserved `_meta` keys: `openwork/mcpApp` for the launch binding, `openwork/serverTools` for gateway tool names, and `openwork/schemaGuidance` for advisory schema warnings. Apps should read gateway tool names from `result._meta["openwork/serverTools"]`, not from provider `structuredContent.serverTools`.

Schema guidance is also appended as a separate text content block under `openwork/schemaGuidance` so the model can use it without seeing private result `_meta`. Warnings do not invalidate a provider success or justify repeating a successful call. Gateway-generated error payloads may still include `schemaGuidance`; those payloads are not provider structured data.

## Requirements OpenWork checks

* The tool's `_meta.ui.resourceUri` is a string starting with `ui://`.
* If `_meta.ui.visibility` is set, it includes `"app"`.
* `resources/read` for that URI returns exactly one content item with `mimeType` `text/html;profile=mcp-app`, at most 768 KiB.
* Optional `_meta.ui.csp` on the resource supports `connectDomains`, `resourceDomains`, `frameDomains`, and `baseUriDomains`: HTTPS origins only, up to 16 each. `permissions` and `domain` are not supported; OpenWork rejects a resource containing either.
* The server is reachable over HTTP(S) as a remote Streamable HTTP MCP server. Local stdio servers cannot be added as an organization connection.
* Mark read-only tools with `annotations: { readOnlyHint: true }` and do not mark them destructive. Helpers with this annotation and without `destructiveHint: true` can run automatically in an active writable App. Other helper calls require a recent single-use trusted click. Launch tools without the read-only annotation, or with `destructiveHint: true`, run only on request.
* OpenWork uses the tool's `title`, or `annotations.title`, as the App's name.

## Example: a Team budget widget

Install `@modelcontextprotocol/sdk`, `@modelcontextprotocol/ext-apps`, and `express`, then create these two files.

<Steps>
  <Step title="Create the MCP server">
    ```ts server.ts theme={null}
    import express from "express";
    import { readFile } from "node:fs/promises";
    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
    import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
    import {
      registerAppResource,
      registerAppTool,
      RESOURCE_MIME_TYPE,
    } from "@modelcontextprotocol/ext-apps/server";

    const server = new McpServer({ name: "team-budget", version: "1.0.0" });
    const html = await readFile(new URL("./view.html", import.meta.url), "utf8");

    registerAppResource(
      server,
      "budget-view",
      "ui://team-budget/view.html",
      { mimeType: RESOURCE_MIME_TYPE },
      async () => ({
        contents: [{
          uri: "ui://team-budget/view.html",
          mimeType: RESOURCE_MIME_TYPE,
          text: html,
        }],
      }),
    );

    registerAppTool(
      server,
      "get_budget",
      {
        title: "Team budget",
        description: "Current budget allocation by team",
        inputSchema: {},
        annotations: { readOnlyHint: true },
        _meta: { ui: { resourceUri: "ui://team-budget/view.html" } },
      },
      async () => {
        const data = { teams: [
          { name: "Engineering", percent: 42 },
          { name: "Sales", percent: 33 },
          { name: "Operations", percent: 25 },
        ] };
        return {
          content: [{ type: "text", text: JSON.stringify(data) }],
          structuredContent: data,
        };
      },
    );

    const app = express();
    app.use(express.json());
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    await server.connect(transport);
    app.all("/mcp", (req, res) => transport.handleRequest(req, res, req.body));
    app.listen(3000);
    ```
  </Step>

  <Step title="Create the view">
    Bundle this file with Vite so it resolves the package import, or serve the import through an ESM CDN.

    ```html view.html theme={null}
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Team budget</title>
        <style>
          body { font: 14px system-ui; margin: 0; padding: 16px; color: #18181b; }
          h2 { font-size: 16px; margin: 0 0 16px; }
          .team { display: grid; grid-template-columns: 100px 1fr 40px; gap: 8px; margin: 10px 0; }
          .track { background: #e4e4e7; border-radius: 4px; overflow: hidden; }
          .bar { background: #2563eb; height: 100%; }
          .value { text-align: right; }
        </style>
      </head>
      <body>
        <h2>Team budget</h2>
        <div id="teams">Loading…</div>
        <script type="module">
          import { App } from "@modelcontextprotocol/ext-apps";

          const root = document.querySelector("#teams");
          const render = (data) => {
            root.replaceChildren(...data.teams.map(({ name, percent }) => {
              const row = document.createElement("div");
              row.className = "team";
              row.innerHTML = `<span>${name}</span><span class="track"><span class="bar" style="display:block;width:${percent}%"></span></span><span class="value">${percent}%</span>`;
              return row;
            }));
          };

          const app = new App({ name: "Team budget", version: "1.0.0" });
          app.addEventListener("toolresult", (params) => render(params.structuredContent));
          await app.connect();
        </script>
      </body>
    </html>
    ```
  </Step>

  <Step title="Add it to OpenWork">
    Run the server on a public HTTPS URL, or use a tunnel for testing. To try it yourself, [add it as a workspace MCP](/docs/start-here/connect-your-stack/add-an-mcp-server). To share it with your organization, add it in OpenWork Cloud under **Manage › Connectors** ([shared MCP connections](/docs/cloud/share-with-your-team/shared-mcp-connections)). Then ask the agent to call `get_budget`, and the App renders inline in chat.
  </Step>
</Steps>

## Tips

* Keep the resource self-contained. If it loads remote assets, declare their origins in `_meta.ui.csp`.
* Return shared result data in `structuredContent` and readable fallback text in `content`. Treat both as model-visible; put view-only data in result `_meta`.
* One tool can drive one view. Ship several tools for several widgets.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.