Parlane

Manifest reference

Served at /.well-known/parlane.json over HTTPS, or returned by the MCP manifest resource parlane://manifest. It declares identity and which surfaces your backend supports. Legacy manifests still parse, but the next iOS release requires an accepteddataProcessing disclosure before an external connection sends content. See the consent migration guide. Replace the example operator and policy with your own actual practices. Surface visibility comes from the declaration alone: the client shows only the surfaces you declare and does not infer them from MCP capability discovery, so in practice you will declare at least chat.

Minimal example

{
  "name": "House Brain",
  "dataProcessing": {
    "version": "2026-09-12.1",
    "operator": "Example developer",
    "privacyPolicyUrl": "https://example.com/privacy",
    "dataTypes": [
      "messages",
      "voiceTranscripts",
      "attachments",
      "actionInputs"
    ],
    "purpose": "Respond to requests and perform explicitly requested actions.",
    "recipients": [],
    "retention": "Demo data lasts only for the current server process. Production operators must publish their own retention policy.",
    "usesForTraining": false
  },
  "surfaces": {
    "chat": { "endpoint": "mcp" }
  }
}

Top-level fields

FieldTypeDefaultMeaning
versioninteger ≥ 11Protocol schema version. Additive changes don't bump it; only breaking ones do.
namestringhost of the URLDisplay name of the connected app.
iconstring (uri)generated initialsAbsolute https URL to a square icon.
accentstring ^#[0-9a-fA-F]{6}$neutral system accentHex color that tints this app's chrome.
dataProcessingobjectnoneOperator, named recipients, purposes, retention and training policy. Required for content sharing in the next release.
descriptionstring ≤ 500noneShown on the connect/consent screen.
surfacesobjectnone advertisedThe surface declarations below.
authobjectbearerHow the client authenticates: { type: "bearer" | "oauth2", scopes?, authorizationServer? }. oauth2 triggers the in-app OAuth 2.1 PKCE login (MCP transport only; REST stays bearer). The client handles discovery, dynamic client registration, the PKCE exchange, and refresh. See the integration kit §6a.

Surfaces

All surface keys are optional. Omit a surface to leave it unadvertised.

  • chat: { endpoint: "mcp"|"rest" = "mcp", attachments: boolean = false }. Streaming text, markdown, tool-activity, and (if attachments) image/text/URL uploads.
  • voice: { mode: "ptt" = "ptt", stt: "device" = "device", tts }. v1 is push-to-talk with on-device STT/TTS; mode and stt accept only their v1 values. From v1.1, tts can be an object { mode: "server", endpoint, format? }: the client posts reply text to your endpoint and plays back the returned audio, falling back to device TTS on error or timeout.
  • dashboards: array of { id, title, source, refresh? }. source is an MCP resource URI, a REST path, or an HTTP URL. MCP resources require an MCP connection. Credentials accompany only same-origin HTTP requests; external sources must be public HTTPS endpoints. Redirects are rejected. Refresh supports manual and polling modes; push is reserved and currently behaves as manual.
  • push: { relay: boolean = false, relayUrl? }.To enable push notifications you must declare relay: true. Without it there is no push. That one flag wires the app to the Parlane-operated hosted relay automatically (only it holds the Apple key). The hosted relay is the default, so you do not set relayUrl unless you are overriding the host, which is rare. You never supply Apple credentials. The simplest setup is to pair the relay at connect time in a single scan (the connect QR carries extra relay and pair params); the app also has a manual pairing fallback. See relay self-hosting.
  • shortcuts: array of { phrase, tool }, each becomes a Siri/App Intent.
  • share: { accepts: ["url"|"text"|"image"|"file"], acceptsMimes?, tool }. The app only offers your target for the kinds you list. Include image/file if you ingest screenshots or documents, or you are never offered for them. Your tool receives params.attachments[] (images/files as base64, per attachment.schema.json) plus flat note/url/text. Accept request bodies ≥ 20 MB on that endpoint: base64 adds ~33% over the 10 MB cap, so a default 1 MB limit returns 413 on a screenshot. Optional acceptsMimes is a MIME allow-list refining image/file: full types (application/pdf) or a trailing wildcard (image/*); the app pre-filters and explains unsupported types before upload. When a share is rejected, return the standard actionable codes so the failure says what to fix: { error: { code, message, details } } with unsupported_kind { kind }, unsupported_mime { mime, accepted? }, or attachment_too_large { maxBytes } (see integration kit §8).
  • capture: { url?, endpoint?, tool?, converseReply: "push" }. Background quick-capture from the iOS Action Button or a Siri Shortcut, without opening the app. All fields optional: with no capture declared, notes post to your existing chat surface, so most backends need nothing. See the integration kit §10.

Full example

Exercises every surface, including the v1.1 server-TTS field.

{
  "version": 1,
  "name": "Ship's Computer",
  "dataProcessing": {
    "version": "2026-09-12.1",
    "operator": "Example developer",
    "privacyPolicyUrl": "https://example.com/privacy",
    "dataTypes": [
      "messages",
      "voiceTranscripts",
      "attachments",
      "actionInputs"
    ],
    "purpose": "Respond to requests and perform explicitly requested actions.",
    "recipients": [],
    "retention": "Demo data lasts only for the current server process. Production operators must publish their own retention policy.",
    "usesForTraining": false
  },
  "icon": "https://demo.example.com/assets/ships-computer.png",
  "accent": "#37E0C8",
  "description": "The ship's computer for a fictional smart vessel and hobby lab.",
  "surfaces": {
    "chat": { "endpoint": "mcp", "attachments": true },
    "voice": {
      "mode": "ptt",
      "stt": "device",
      "tts": { "mode": "server", "endpoint": "/tts", "format": "mp3" }
    },
    "dashboards": [
      {
        "id": "home",
        "title": "Bridge",
        "source": "parlane://ui/home",
        "refresh": { "mode": "poll", "intervalSeconds": 15 }
      },
      { "id": "systems", "title": "Systems", "source": "parlane://ui/systems" }
    ],
    "push": { "relay": true, "relayUrl": "https://relay.parlane.ai" },
    "shortcuts": [
      { "phrase": "status report", "tool": "status_summary" }
    ],
    "share": {
      "accepts": ["url", "text", "image", "file"],
      "acceptsMimes": ["image/png", "image/jpeg", "image/webp", "application/pdf"],
      "tool": "log_to_research_bay"
    },
    "capture": { "endpoint": "/capture", "converseReply": "push" }
  }
}

The component catalog (for dashboards)

A dashboard is a UI document: { version: 1, id?, title?, root: Component }. Every component is { type, id?, props?, children?, action? }. The 17 fixed types:

typerequired propsoptional propskind
stacknonedirection, spacing, aligncontainer
cardnonetitle, footercontainer
textvaluestyle: title|body|caption|monoleaf
metriclabel, valuedelta, intent: good|bad|neutral|infoleaf
chartkind, serieslabels[]leaf
tablecolumns[], rows[][]maxHeightleaf
listitems[]noneleaf
imageurlaspectleaf
buttonlabelintent (requires action)leaf
inputlabel, bindvalue, placeholder, keyboardleaf
togglelabel, bindvalueleaf
sliderlabel, bindvalue, min, max, stepleaf
selectlabel, bind, options[]valueleaf
progressvaluelabelleaf
badgevalueintent: good|bad|neutral|infoleaf
dividernonenoneleaf
maplat, lngzoom, markers[]leaf

Limits, stated plainly: max depth 12, max 500 nodes per document, enforced in code (not JSON Schema, which can't cleanly bound recursion). An unknown component type renders as a labeled placeholder rather than crashing. This keeps older clients forward-compatible with future catalog additions. There is no patch format: every update is a full replacement document, diffed by id on the client.

Intent is sentiment, not a color

Components don't carry a color; they carry an intent, and the client resolves it against the active theme so a control stays legible in light and dark mode. A button maps primary to the accent, destructive to the negative semantic color, and plain to a bordered button. A metric or badge maps good/bad/neutral/info to the semantic palette. A manifest theme sets which colors those tokens resolve to within your app's screens: theme.colors.accent drives primary, and theme.colors.negative drives destructive and bad. To make a button red, set intent: "destructive", not a color. See the integration kit §4.5.

Recipe: content left, controls right

For a row with a label on the left and controls on the right (an approve/reject row, for example), use a full-width horizontal stack with justify: "spaceBetween": a vertical text stack that wraps on the left, and a horizontal stack of short-label buttons on the right. The text column shrinks and wraps, the control column holds its intrinsic width and stays within the card and safe-area bounds, and every control has a 44 pt minimum tap target.

{
  "type": "stack",
  "props": { "direction": "horizontal", "justify": "spaceBetween", "align": "center", "spacing": 12 },
  "children": [
    {
      "type": "stack",
      "props": { "direction": "vertical", "spacing": 2 },
      "children": [
        { "type": "text", "props": { "value": "Deploy web #1423", "style": "body" } },
        { "type": "text", "props": { "value": "Staging passed 4 minutes ago", "style": "caption" } }
      ]
    },
    {
      "type": "stack",
      "props": { "direction": "horizontal", "spacing": 8 },
      "children": [
        { "type": "button", "props": { "label": "Approve", "intent": "primary" }, "action": { "tool": "approve_deploy", "params": { "id": "1423" } } },
        { "type": "button", "props": { "label": "Reject", "intent": "destructive" }, "action": { "tool": "reject_deploy", "params": { "id": "1423" } } }
      ]
    }
  ]
}

Manifest caching and refresh

The app caches your manifest and refreshes it in the background, so a change you deploy shows up on its own. It refetches when the user enters your app's screens and each time the app returns to the foreground, debounced to at most once per app every 5 minutes. To pick up a newly declared surface, deploy and foreground the app: you don't need to reconnect. To force a refresh while testing, fully quit and relaunch the app. Dashboards cache separately, so a dashboard JSON change appears the next time that dashboard is fetched. See the integration kit §9.1.

Next

REST contract → · Relay self-hosting →