Skip to content

App Protocol

For developers. Start with the developer manual to choose API work, App publishing, or project changes.

The OpenBot App Protocol is an open way to publish an App on the web. Any Bot host can then find the App, connect to it, and use it. You do not need a developer account, a registry, or a review. An HTTPS address that you control is enough.

This page describes draft V1.1. In this page, “must” means a rule, “should” means a strong advice, and “may” means an option. All domains are examples.


The protocol adds very little of its own. It picks existing standards and puts them together.

Part Question it answers
AI Catalog 1.0 Which services are here?
MCP Server Card v1 How do I connect?
MCP 2026-07-28 over Streamable HTTP What can the App do?
MCP Apps 2026-01-26 How does it show a view?

MCP (Model Context Protocol) is a standard way for AI hosts to call tools on a server. The protocol has no own RPC methods, fixed tool names, or UI language.


To be found by domain, put an AI Catalog at /.well-known/ai-catalog.json. Serve it as application/ai-catalog+json.

{
"specVersion": "1.0",
"entries": [
{
"identifier": "urn:air:apps.example.com:mcp:calendar",
"type": "application/mcp-server-card+json",
"url": "https://apps.example.com/mcp/calendar/server-card"
}
]
}

A Catalog may point to Cards on other domains. This lets someone else host the service for you.

Each App must have its own stable Card URL. Serve the Card as application/mcp-server-card+json. It must list at least one HTTPS endpoint that supports MCP 2026-07-28.

{
"$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
"name": "com.example/calendar",
"title": "Example Calendar",
"version": "1.0.0",
"description": "Look up schedules and create calendar events.",
"websiteUrl": "https://apps.example.com/apps/calendar",
"remotes": [
{
"type": "streamable-http",
"url": "https://apps.example.com/mcp/calendar",
"supportedProtocolVersions": ["2026-07-28"]
}
]
}

The Card URL is the App’s identity. The name and the Catalog identifier only describe the App. Two Apps with the same name are never merged.

A remote may ask for settings, such as headers or URL variables. Mark secrets with isSecret: true. Hosts keep these values private to each user.

Serve the Catalog and the Card with ETag or Last-Modified. Hosts send conditional requests and expect 304 Not Modified when nothing changed.

Use the standard Card icons list (src, mimeType, sizes, theme). Hosts must still show your App when an icon is missing. Hosts never run SVG files as HTML.

You may add usage examples under _meta["openbot/presentation"]:

{
"_meta": {
"openbot/presentation": {
"version": "1",
"examples": [
{
"id": "meeting-plan",
"title": "Plan the next team meeting",
"prompt": "Find a free 30-minute slot next week and draft an agenda.",
"description": "A sample week with one proposed meeting.",
"preview": {
"src": "https://apps.example.com/assets/meeting.webp",
"alt": "A sample week with one proposed meeting",
"width": 1200,
"height": 800,
"mimeType": "image/webp"
}
}
]
}
}
}
Field Rule
version Must be "1".
examples 1 to 12 items.
id Unique. Up to 100 letters, digits, ., _ or -. Starts with a letter or digit.
title Up to 100 characters.
prompt Up to 2000 characters.
description Optional. Up to 500 characters.
preview Optional. HTTPS src, alt up to 500 characters, width and height from 1 to 8192, mimeType one of PNG, JPEG, WebP, AVIF or SVG.

Examples must only hold public or made-up data. Hosts show them as plain text. Looking at or picking an example never adds an App, signs in, or runs a tool. Hosts ignore a broken presentation block and keep using the Card.


A host must accept four kinds of input: a domain, an App page URL, a Catalog URL, or a Card URL.

  • Domain. The host reads https://<domain>/.well-known/ai-catalog.json.

  • App page. The page points to its Catalog with a link tag or an HTTP Link header:

    <link
    rel="ai-catalog"
    type="application/ai-catalog+json"
    href="/apps/calendar/catalog.json"
    />

    The host reads only these static links. It never runs page scripts. It keeps the full path, so a page for one App does not turn into the whole domain.

  • Catalog with many Cards. The host asks the user to choose. It never adds a whole Catalog.

An App Link is a normal HTTPS URL, best an App page. A QR code holds the same URL. There is no special QR format or URI scheme. Links must not hold tokens or private data. Opening a link only starts a review. It grants no access and runs nothing.


Adding, signing in, and running are three separate steps. Adding saves the connection. Signing in grants clear access. Running needs the user’s intent, a valid sign-in, and the host’s own rules.

After the user adds the App, the host:

  1. calls server/discover to read the live version and capabilities;
  2. calls tools/list, resources/list and prompts/list;
  3. does the work with tools/call, resources/read and prompts/get.

All messages, results, and errors follow MCP. Name your tools by what they do, such as create_event. Describe input with inputSchema. Return structured output with outputSchema and structuredContent. Always return a clear text result too.

An App that needs a user account must use MCP authorization. This is OAuth 2.1:

  • Protected Resource Metadata. The App answers 401 or 403 with a challenge that points to its metadata. The metadata names the authorization servers and scopes.
  • Client ID Metadata Document. The host’s client ID is an HTTPS URL to a public JSON document. Authorization servers read it. No one has to register the host by hand. Both sides must support this.
  • PKCE with S256. It protects the sign-in code. Requests also send the exact resource.

Your App may still ask for a login, a plan, or a business permission. This is not a registry step.


An App may show an interactive view. A tool links to a UI resource in its metadata:

{
"_meta": {
"ui": {
"resourceUri": "ui://calendar/planner.html",
"visibility": ["model", "app"]
}
}
}

The resource must use a ui:// URI and the type text/html;profile=mcp-app. The host reads it with resources/read. The host declares io.modelcontextprotocol/ui in its client capabilities.

Purpose Standard method Direction
Start and say ready ui/initialize, ui/notifications/initialized View to host
Get tool input and result ui/notifications/tool-input, ui/notifications/tool-result Host to view
Do work or read data tools/call, resources/read View, through host, to App
Update the model context ui/update-model-context View to host
Send a chat message ui/message View to host
Open a link ui/open-link View to host
Share theme and size ui/notifications/host-context-changed, ui/notifications/size-changed Both ways
Clean up ui/resource-teardown Host to view

Rules for views:

  • The host waits until the view is ready before it sends tool data.
  • A tool with visibility: ["app"] is only for the view. Visibility is not access control. Your server must still check permissions.
  • If a view fails to load, the host must not run the tool again. The text result is still there.

App records, Index entries and connections

Section titled “App records, Index entries and connections”

A host keeps three separate things:

Thing Holds Shared?
App record The Card and where it came from. Per account
Index entry The public Card and the publisher evidence. Used for search. Public
User connection The user’s settings, sign-in, and access. Private to the user

Many users can connect the same App. Removing one connection never affects another user or the Index. A public Index that is down must not block a known connection.


When you list a Card in your public AI Catalog, you allow public indexing of it. The host must confirm this from your own documents. A copy in someone else’s Catalog is only a hint.

The crawler works without user data. It uses the user bot token OpenBot and follows robots.txt (RFC 9309). It accepts publisher evidence from:

  • the Catalog at the Card origin’s /.well-known/ai-catalog.json;
  • a Catalog named by an ai-catalog Link header on the Card response;
  • a Catalog linked from the page in the Card’s websiteUrl.

To opt out, send X-Robots-Tag: noindex on the Card. noindex is not access control. If you remove the Card from your Catalog, the host removes the Index entry at its next check. A Catalog that cannot be reached is not a removal.

No registry is needed to publish, add, or call an App. A host may also read other registries. See how OpenBot does this in App Index.


  • All connections use HTTPS. Hosts show the real source of each App.
  • Names, icons, Index entries, and tool notes are not trust signals. App content is data, never host instructions.
  • Public crawlers limit size, time, and redirects. They check each target and never reach private, loopback, or cloud metadata addresses. The library defaults are 1 MiB, 10 seconds, and 5 redirects.
  • Hosts also limit the size of each MCP message. The library default is 16 MiB for a JSON response or for one event of an event stream.
  • Discovery from one input stops after 64 documents or 60 seconds. It follows at most 64 links from one Catalog or page. A public indexing check reads at most 8 publisher Catalogs and stops after 60 seconds. The library uses these limits.
  • Writes need a permission check. Writes that may be retried need a business idempotency key.
  • When the result of a call is unknown, the host must not retry it blindly.
  • A change of endpoint, provider, or scopes needs a new review. Old tokens never move to a new target.
  • Removing a connection stops all calls and deletes its tokens.

@openbot-build/protocol implements this protocol with Effect 4. It is version 0.1.0 and is not yet on npm. Use it inside this repository’s pnpm workspace.

Entry point Use
McpPublisher Serve a Card, a Catalog, and an MCP endpoint.
McpPublisher.layerProtected, Protection Protect an endpoint with OAuth.
Discovery, CatalogDiscovery Find Apps from a domain or link.
AppConnections, McpClient Add, call, revalidate, and remove connections.
OAuthAuthorization Run the host side of account sign-in.
Indexing, PublicIndexing, RobotsPolicy Check and index public Apps.
@openbot-build/protocol/NodeHttpServer Node network layers with the public network rules.
@openbot-build/protocol/McpApps Browser host for MCP Apps views.

McpPublisher.layer and layerProtected take plain publication data and check it once when the layer starts. registerUi checks plain UI resource data in its Effect. Bad input fails with a typed error.

McpClient.open opens a connection from its key, endpoint, headers, and client data. Give it readToken to read the current token and check to check each action. A host can also give registerScope to track the connection before any network call. The client needs no App or credential store. Its scope owns the connection and closes it. For a saved connection, use AppConnections.open. That adapter reads the current connection and credentials, checks access, and tracks its scope. Token reads and sends still check the current state.

Opening a client does not read capability lists. Use client.resolve({ kind, name }, source) to find one tool, resource, template, or prompt. It reads only that kind of list and stops at the first match. A match gives the declaration and its invoke and resume operations. They use that same declaration without reading the list again. Pass request parameters without the bound name or URI to invoke; a template takes { values }. Keep the returned action inside its connection scope. A restored phase finds and checks the declaration again. Direct readResource also supports UI resources that are not in the resource list.

CatalogDiscovery.sameConnection compares the parts of a remote that affect a connection. A change to description text or an extension field keeps the connection and its sign-in. A change to an input format or its secret flag needs a new check.

OAuth network failures keep NetworkError. They do not mean that the provider refused a grant. Closing the connection scope stops pending SDK requests, including requests that still wait for response headers or stored tokens.

For a host with durable execution, use OAuthAuthorization.prepare, redeem, renew, and revokeCredential. These functions return a flow or grant without writing the host’s stores. Save a flow before showing its sign-in URL. Record a code exchange or token refresh before sending it. Seal the returned grant, then commit it with the connection state. A lost exchange result needs a decision; do not send the code or refresh token again without one. Revocation uses a fixed grant and can be repeated. The standalone begin, complete, refresh, and revoke functions also update the given stores. They do not provide recovery across process restarts.

Publish a small App:

import { Document, McpPublisher } from "@openbot-build/protocol"
import { Effect, Schema } from "effect"
import { McpServer, Tool, Toolkit } from "effect/ai"
const tools = Toolkit.make(
Tool.make("hello", {
parameters: Schema.Struct({ name: Schema.String }),
success: Schema.Struct({ greeting: Schema.String }),
}),
)
const handlers = tools.toLayer({
hello: ({ name }) => Effect.succeed({ greeting: `Hello, ${name}` }),
})
const origin = "https://apps.example.com"
export const publisher = McpPublisher.layer({
publication: {
card: {
$schema: Document.cardSchemaUrl,
name: "com.example/hello",
version: "1.0.0",
description: "Say hello.",
remotes: [
{
type: "streamable-http",
url: origin + "/mcp",
supportedProtocolVersions: [Document.protocolVersion],
},
],
},
cardUrl: origin + "/mcp/server-card",
catalogUrl: origin + "/.well-known/ai-catalog.json",
mcpUrl: origin + "/mcp",
allowedOrigins: ["https://host.example.com"],
},
register: McpServer.registerToolkit(tools).pipe(Effect.provide(handlers)),
})

Serve publisher with Effect HttpRouter.serve. Building the layer does no I/O. The publication also takes etag (a quoted entity tag), lastModified (an HTTP date, such as Fri, 09 Oct 2026 00:00:00 GMT), cacheControl, and noindex. Use McpPublisher.registerUi to add a ui:// view resource.

Discover an App:

import { CatalogDiscovery, Discovery } from "@openbot-build/protocol"
import * as NodeHttpServer from "@openbot-build/protocol/NodeHttpServer"
import { Effect, Layer } from "effect"
const discovery = CatalogDiscovery.layer().pipe(
Layer.provide(CatalogDiscovery.layerRules),
Layer.provide(NodeHttpServer.layerPublicNetwork()),
)
const result = await Effect.runPromise(
Discovery.resolve("apps.example.com").pipe(Effect.provide(discovery)),
)

The result is Single or Multiple. To test your own App against a real host, use the protocol test service and the developer tools.