{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://orbtile.com/schemas/adapter-protocol/v1.json",
  "title": "Orbtile Adapter Protocol v1.2",
  "description": "Off by default: the user turns it on in Orbtile › Settings › Agents › Adapter API. Request and response bodies of the local Orbtile Adapter API. Transport: HTTP/1.1 over the Unix-domain socket named by `socketPath` in the discovery file (~/Library/Application Support/Orbtile/adapter-api.json). Request target = `basePath` from the same file (\"/a/v1\") + the endpoint path; Host: localhost. All requests: Content-Type: application/json, Authorization: Bearer <token from the discovery file>, no Origin header. Servers ignore unknown request fields and report them in `warnings`; clients ignore unknown response fields. v1.1 adds optional event capabilities. v1.2 adds optional adapter icons and per-state colours. Interactive registration returns adapterToken in its JSON reply; send it as X-Orbtile-Adapter-Token on every adapter route. GET /info advertises apiMinor: 2. v1.0 and v1.1 registrations remain valid.",
  "$defs": {
    "AdapterID": {
      "description": "Reverse-DNS id owned by the adapter author. Lowercase, at least one dot, 3..64 bytes. Ids without a dot are reserved for built-in adapters; com.orbtile.* is reserved.",
      "type": "string",
      "pattern": "^[a-z0-9]+(-[a-z0-9]+)*(\\.[a-z0-9]+(-[a-z0-9]+)*)+$",
      "minLength": 3,
      "maxLength": 64
    },
    "SessionID": {
      "type": "string",
      "pattern": "^[A-Za-z0-9._:-]{1,128}$"
    },
    "Activity": {
      "description": "Orbtile orb states. 'empty' is renderer-internal and is rejected.",
      "enum": [
        "starting",
        "idle",
        "thinking",
        "tool",
        "streaming",
        "waitingPermission",
        "waitingQuestion",
        "done",
        "error",
        "compacting",
        "offline"
      ]
    },
    "Glyph": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "symbol": {
          "description": "SF Symbol name shown in Settings and the popover.",
          "type": "string",
          "pattern": "^[a-z0-9.]{1,64}$"
        },
        "monogram": {
          "description": "1-2 characters, used when the symbol is missing or unknown.",
          "type": "string",
          "minLength": 1,
          "maxLength": 2
        }
      },
      "minProperties": 1
    },
    "Icon": {
      "description": "Optional adapter icon. Supply PNG data, a macOS app bundle id, or both. Null or omission uses the glyph. Orbtile validates PNG bytes and dimensions, then re-encodes 28x28 and 56x56 sRGB icons of at most 16384 bytes each.",
      "type": "object",
      "properties": {
        "png": {
          "description": "Standard RFC 4648 base64 without whitespace. 1..43696 ASCII characters decode to 1..32768 PNG bytes. The PNG must have square IHDR dimensions from 16 through 256 pixels.",
          "type": "string",
          "minLength": 1,
          "maxLength": 43696,
          "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
          "contentEncoding": "base64"
        },
        "appBundleId": {
          "description": "Installed macOS app bundle identifier. On macOS it takes precedence when resolved; PNG is the fallback and the cross-platform icon.",
          "type": "string",
          "minLength": 3,
          "maxLength": 128,
          "pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
        }
      },
      "anyOf": [
        {"required": ["png"]},
        {"required": ["appBundleId"]}
      ]
    },
    "StateColours": {
      "description": "One to ten adapter-selected sRGB state colours. error and empty are not allowed. thinking supplies omitted tool and streaming colours; waitingPermission supplies omitted waitingQuestion. A user hue replaces all adapter state colours; anchorAttention ignores both waiting overrides.",
      "type": "object",
      "minProperties": 1,
      "maxProperties": 10,
      "propertyNames": {
        "enum": [
          "idle",
          "starting",
          "thinking",
          "tool",
          "streaming",
          "done",
          "compacting",
          "offline",
          "waitingPermission",
          "waitingQuestion"
        ]
      },
      "additionalProperties": {
        "type": "string",
        "pattern": "^#[0-9A-Fa-f]{6}$"
      }
    },
    "Palette": {
      "description": "The adapter's palette. Orbtile generates per-session colours from baseHue and optional per-state colours. A user baseHue override in Settings replaces palette.states. The error colours and the neutrals are fixed: they are the same for every adapter.",
      "type": "object",
      "additionalProperties": false,
      "required": [
        "baseHue"
      ],
      "properties": {
        "baseHue": {
          "description": "OKLCH hue (degrees) of the working orb. Hues inside the reserved arc 345.9..75.9 (within 45 degrees of the error hue 30.9) are moved to the nearer edge (345.9 or 75.9) and reported in `warnings`. Avoid 205..215: the waiting colour changes direction at 210.9, so pick one side.",
          "type": "number",
          "minimum": 0,
          "exclusiveMaximum": 360
        },
        "chroma": {
          "description": "Colourfulness multiplier for generated adapter and waiting colours. Explicit palette.states entries use their own chroma ratio instead. Default 1.",
          "type": "number",
          "minimum": 0.5,
          "maximum": 1.3
        },
        "lightness": {
          "description": "OKLab L shift of the lit stops of the adapter family. The deep stop never moves. Default 0.",
          "type": "number",
          "minimum": -0.05,
          "maximum": 0.05
        },
        "attentionHue": {
          "description": "Optional OKLCH hue (degrees) of the waitingPermission orb, the adapter's alarm colour. Omit it to derive waiting from baseHue. Set it when the generator's `attention:` note says the derived waiting colour is in the calm arc (base hues about 115..235). Hues inside 18.9..55 (the error hue up to past Claude's terracotta) are moved to the nearer edge. Stay in the alarm arc (280 -> 0 -> 76, clear of Claude gold) and away from the built-in waiting colours: Claude terracotta (about 43), Codex violet (about 306), ZCode rose (355). Every alarm-arc hue is currently under 10 deltaE_OK x100 from one of them, so expect a `proximity:` warning; the widest gaps are about 72..75, 330 and 16..18.",
          "type": "number",
          "minimum": 0,
          "exclusiveMaximum": 360
        },
        "states": {
          "description": "Optional v1.2 state colours. Null or omission keeps generated v1.0/v1.1 behaviour; an empty object is invalid.",
          "oneOf": [
            {"$ref": "#/$defs/StateColours"},
            {"type": "null"}
          ]
        }
      }
    },
    "AdapterRegistration": {
      "description": "PUT /adapters/{adapterId}",
      "type": "object",
      "required": [
        "name",
        "palette"
      ],
      "properties": {
        "name": {
          "type": "string",
          "minLength": 1,
          "maxLength": 32
        },
        "shortName": {
          "description": "Badge text, defaults to name.",
          "type": "string",
          "minLength": 1,
          "maxLength": 12
        },
        "palette": {
          "$ref": "#/$defs/Palette"
        },
        "glyph": {
          "$ref": "#/$defs/Glyph"
        },
        "icon": {
          "description": "Optional v1.2 adapter icon. Re-registering with null or omitting icon clears a previous custom icon.",
          "oneOf": [
            {"$ref": "#/$defs/Icon"},
            {"type": "null"}
          ]
        },
        "version": {
          "type": "string",
          "maxLength": 32
        },
        "homepage": {
          "type": "string",
          "format": "uri",
          "pattern": "^https://",
          "maxLength": 512
        },
        "urlSchemes": {
          "description": "Schemes this adapter may use in session openURL. 'file', 'javascript', 'data', 'orbtile' and 'x-apple.systempreferences' are always refused.",
          "type": "array",
          "maxItems": 4,
          "uniqueItems": true,
          "items": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9+.-]{0,31}$"
          }
        },
        "capabilities": {
          "description": "Optional v1.1 event capabilities. Empty by default; declaring approvals/questions does not enable keypad decisions: the user must opt in separately under Settings > Agents > Keypad.",
          "type": "array",
          "maxItems": 3,
          "uniqueItems": true,
          "items": {
            "enum": [
              "tap",
              "approvals",
              "questions"
            ]
          }
        },
        "tapBehavior": {
          "description": "alsoOpen (default) emits tap and also opens registered URL/app. eventOnly emits tap without opening. User tap preferences still apply.",
          "enum": [
            "alsoOpen",
            "eventOnly"
          ],
          "default": "alsoOpen"
        }
      },
      "allOf": [
        {
          "if": {
            "required": [
              "tapBehavior"
            ]
          },
          "then": {
            "required": [
              "capabilities"
            ],
            "properties": {
              "capabilities": {
                "contains": {
                  "const": "tap"
                }
              }
            }
          }
        }
      ]
    },
    "Message": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "id",
        "text"
      ],
      "properties": {
        "id": {
          "description": "Identity of the text; a new id restarts the typewriter.",
          "type": "string",
          "minLength": 1,
          "maxLength": 128
        },
        "text": {
          "description": "Whole latest assistant text; the server keeps the last 4096 bytes.",
          "type": "string",
          "maxLength": 16384
        }
      }
    },
    "SessionUpdate": {
      "description": "POST /adapters/{adapterId}/sessions/{sessionId}. Merge semantics: an absent field keeps its value, null clears it. 'title' and 'activity' are required when the session does not exist yet.",
      "type": "object",
      "properties": {
        "title": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "activity": {
          "$ref": "#/$defs/Activity"
        },
        "cwd": {
          "type": [
            "string",
            "null"
          ],
          "maxLength": 1024
        },
        "project": {
          "description": "Optional per-session Project group (Orbtile 0.2.17+). Display text is cleaned and trimmed before checking 1...128 Unicode scalars. Overrides cwd for Project grouping within the session host; null restores cwd fallback. Additive and v1-compatible.",
          "type": [
            "string",
            "null"
          ],
          "minLength": 1,
          "maxLength": 128
        },
        "model": {
          "type": [
            "string",
            "null"
          ],
          "maxLength": 64
        },
        "toolLabel": {
          "description": "Shown while activity is 'tool'.",
          "type": [
            "string",
            "null"
          ],
          "maxLength": 80
        },
        "toolKind": {
          "description": "Tool motion while activity is 'tool'. Unknown values count as none (a warning).",
          "type": [
            "string",
            "null"
          ],
          "maxLength": 32,
          "examples": [
            "shell",
            "edit",
            "read",
            "web",
            "delegate",
            "plan",
            "integration",
            "other"
          ]
        },
        "message": {
          "oneOf": [
            {
              "$ref": "#/$defs/Message"
            },
            {
              "type": "null"
            }
          ]
        },
        "statusDetail": {
          "type": [
            "string",
            "null"
          ],
          "maxLength": 120
        },
        "subagentCount": {
          "type": "integer",
          "minimum": 0,
          "maximum": 99
        },
        "host": {
          "description": "'local' (default) or an ssh-style alias.",
          "type": "string",
          "pattern": "^[A-Za-z0-9._-]{1,64}$"
        },
        "openURL": {
          "description": "Opened when the user taps the key. Scheme must be declared in urlSchemes.",
          "type": [
            "string",
            "null"
          ],
          "format": "uri",
          "maxLength": 2048
        },
        "pid": {
          "description": "Local process of the session; Orbtile activates its GUI ancestor on tap. Must belong to the calling user.",
          "type": [
            "integer",
            "null"
          ],
          "minimum": 1
        },
        "background": {
          "type": "boolean"
        },
        "liveText": {
          "description": "false = state only (shown like Claude registry-only sessions).",
          "type": "boolean"
        },
        "ttlSeconds": {
          "description": "No update or heartbeat for this long = offline; removed 600 s later. Default 120.",
          "type": "integer",
          "minimum": 10,
          "maximum": 3600
        },
        "approval": {
          "description": "Optional v1.1 pending request, null clears. Exactly one approval or question per session; at most 32 pending requests per adapter.",
          "oneOf": [
            {
              "$ref": "#/$defs/ApprovalRequest"
            },
            {
              "type": "null"
            }
          ]
        },
        "question": {
          "description": "Optional v1.1 pending request, null clears. Exactly one approval or question per session; at most 32 pending requests per adapter.",
          "oneOf": [
            {
              "$ref": "#/$defs/QuestionRequest"
            },
            {
              "type": "null"
            }
          ]
        }
      }
    },
    "SessionBatch": {
      "description": "PUT /adapters/{adapterId}/sessions: full replace. Sessions of this adapter not listed are ended.",
      "type": "object",
      "required": [
        "sessions"
      ],
      "properties": {
        "sessions": {
          "type": "array",
          "maxItems": 32,
          "items": {
            "allOf": [
              {
                "$ref": "#/$defs/SessionUpdate"
              },
              {
                "type": "object",
                "required": [
                  "id",
                  "title",
                  "activity"
                ],
                "properties": {
                  "id": {
                    "$ref": "#/$defs/SessionID"
                  }
                }
              }
            ]
          }
        }
      }
    },
    "RegistrationReply": {
      "type": "object",
      "required": [
        "adapter",
        "enabled"
      ],
      "properties": {
        "adapter": {
          "type": "object",
          "properties": {
            "id": {
              "$ref": "#/$defs/AdapterID"
            },
            "palette": {
              "description": "Effective palette: the user's baseHue override, or the registered value.",
              "$ref": "#/$defs/Palette"
            },
            "resolvedBaseHue": {
              "description": "The base hue the generator uses, after the reserved-arc push.",
              "type": "number"
            }
          }
        },
        "enabled": {
          "type": "boolean"
        },
        "keypadDecisions": {
          "description": "Whether Orbtile delivers this adapter's keypad approval decisions and question answers at reply time (the user's Approve from the keypad switch for this adapter, Settings > Agents). A snapshot, not a promise: register again to re-read it; the user can turn it off later, which clears queued decisions. Added in Orbtile 0.2.15.",
          "type": "boolean"
        },
        "reason": {
          "description": "Why not enabled.",
          "enum": [
            "awaiting_user",
            "disabled_by_user"
          ]
        },
        "warnings": {
          "description": "Unknown request fields, the reserved-arc push, and the palette generator's notes (gamut clips, salience guard, WCAG lifts).",
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "adapterToken": {
          "description": "Ownership lease for every adapter route including capability downgrade/re-registration; 256-bit base64url. Never log or persist. Ends on unregister, silence, token regeneration or host restart.",
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{43}$"
        }
      }
    },
    "SessionReply": {
      "type": "object",
      "required": [
        "session"
      ],
      "properties": {
        "session": {
          "type": "object",
          "properties": {
            "uid": {
              "type": "string"
            },
            "visible": {
              "type": "boolean"
            }
          }
        },
        "warnings": {
          "type": "array",
          "items": {
            "type": "string"
          }
        }
      }
    },
    "Info": {
      "description": "GET /info. With request header X-Orbtile-Nonce (16..64 hex chars) the reply carries proof = hex(HMAC-SHA256(key: token, message: nonce)), which only the real Orbtile can compute.",
      "type": "object",
      "required": [
        "server",
        "api",
        "version",
        "activities",
        "limits"
      ],
      "properties": {
        "server": {
          "const": "orbtile"
        },
        "api": {
          "const": 1
        },
        "version": {
          "type": "string"
        },
        "activities": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/Activity"
          }
        },
        "limits": {
          "type": "object",
          "properties": {
            "maxBodyBytes": {
              "const": 65536
            },
            "maxSessionsPerAdapter": {
              "const": 32
            },
            "maxAdapters": {
              "const": 8
            },
            "requestsPerSecond": {
              "const": 20
            },
            "burst": {
              "const": 60
            },
            "maxPendingRequestsPerAdapter": {
              "const": 32
            },
            "maxEvents": {
              "const": 128
            },
            "maxRequestIDs": {
              "const": 4096
            },
            "maxLongPolls": {
              "type": "integer",
              "const": 4
            }
          }
        },
        "proof": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$"
        },
        "apiMinor": {
          "description": "Additive v1.2 support, including icon and palette.states; discovery still uses api:1 and basePath /a/v1.",
          "type": "integer",
          "minimum": 0
        }
      }
    },
    "Error": {
      "type": "object",
      "required": [
        "error"
      ],
      "properties": {
        "error": {
          "type": "object",
          "required": [
            "code",
            "message"
          ],
          "properties": {
            "code": {
              "enum": [
                "bad_request",
                "invalid_field",
                "unauthorized",
                "forbidden_origin",
                "not_found",
                "adapter_not_registered",
                "reserved_id",
                "length_required",
                "payload_too_large",
                "unsupported_media_type",
                "limit_exceeded",
                "rate_limited",
                "api_disabled",
                "internal",
                "invalid_cursor",
                "events_not_supported",
                "poll_pending"
              ]
            },
            "message": {
              "type": "string"
            },
            "field": {
              "type": "string"
            }
          }
        }
      }
    },
    "RequestID": {
      "description": "Fresh within this registration, shared namespace across sessions and request kinds. Reusing a retired id is refused. Identical pending updates never refresh its expiry.",
      "type": "string",
      "pattern": "^[A-Za-z0-9._:-]{1,128}$"
    },
    "ApprovalRequest": {
      "type": "object",
      "required": [
        "id",
        "toolName",
        "commandSummary"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/RequestID"
        },
        "toolName": {
          "type": "string",
          "minLength": 1,
          "maxLength": 80
        },
        "commandSummary": {
          "type": "string",
          "minLength": 1,
          "maxLength": 512
        }
      }
    },
    "QuestionOption": {
      "type": "object",
      "required": [
        "label"
      ],
      "properties": {
        "label": {
          "type": "string",
          "minLength": 1,
          "maxLength": 120
        },
        "description": {
          "type": "string",
          "maxLength": 512
        }
      }
    },
    "Question": {
      "type": "object",
      "required": [
        "text",
        "options"
      ],
      "properties": {
        "text": {
          "type": "string",
          "minLength": 1,
          "maxLength": 1024
        },
        "header": {
          "type": "string",
          "maxLength": 12
        },
        "multiSelect": {
          "type": "boolean",
          "default": false
        },
        "options": {
          "type": "array",
          "minItems": 2,
          "maxItems": 4,
          "items": {
            "$ref": "#/$defs/QuestionOption"
          }
        }
      }
    },
    "QuestionRequest": {
      "type": "object",
      "required": [
        "id",
        "questions"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/RequestID"
        },
        "questions": {
          "type": "array",
          "minItems": 1,
          "maxItems": 4,
          "items": {
            "$ref": "#/$defs/Question"
          }
        }
      }
    },
    "EventsPoll": {
      "description": "POST /adapters/{adapterId}/events. One outstanding poll. Cumulative ack never decreases; ack <= after <= highest sequence previously delivered. Resume with last locally applied sequence; acknowledge only after authoritative UI commit or deliberate stale-event discard.",
      "type": "object",
      "properties": {
        "after": {
          "type": "integer",
          "minimum": 0,
          "default": 0,
          "maximum": 9007199254740991
        },
        "ack": {
          "type": "integer",
          "minimum": 0,
          "default": 0,
          "maximum": 9007199254740991
        },
        "waitSeconds": {
          "type": "integer",
          "minimum": 0,
          "maximum": 2,
          "default": 0
        }
      }
    },
    "TapEvent": {
      "type": "object",
      "required": [
        "sequence",
        "type",
        "sessionId",
        "host"
      ],
      "properties": {
        "sequence": {
          "type": "integer",
          "minimum": 1
        },
        "sessionId": {
          "$ref": "#/$defs/SessionID"
        },
        "host": {
          "type": "string",
          "pattern": "^[A-Za-z0-9._-]{1,64}$"
        },
        "type": {
          "const": "tap"
        }
      }
    },
    "ApprovalEvent": {
      "type": "object",
      "required": [
        "sequence",
        "type",
        "sessionId",
        "host",
        "requestId",
        "decision",
        "expiresAt"
      ],
      "properties": {
        "sequence": {
          "type": "integer",
          "minimum": 1
        },
        "sessionId": {
          "$ref": "#/$defs/SessionID"
        },
        "host": {
          "type": "string",
          "pattern": "^[A-Za-z0-9._-]{1,64}$"
        },
        "type": {
          "const": "approval"
        },
        "requestId": {
          "$ref": "#/$defs/RequestID"
        },
        "decision": {
          "enum": [
            "allow",
            "deny"
          ]
        },
        "expiresAt": {
          "description": "Unix epoch seconds. Reject on or after this time and if own-UI request no longer pending.",
          "type": "number"
        }
      }
    },
    "QuestionEvent": {
      "type": "object",
      "required": [
        "sequence",
        "type",
        "sessionId",
        "host",
        "requestId",
        "picks",
        "expiresAt"
      ],
      "properties": {
        "sequence": {
          "type": "integer",
          "minimum": 1
        },
        "sessionId": {
          "$ref": "#/$defs/SessionID"
        },
        "host": {
          "type": "string",
          "pattern": "^[A-Za-z0-9._-]{1,64}$"
        },
        "type": {
          "const": "question"
        },
        "requestId": {
          "$ref": "#/$defs/RequestID"
        },
        "picks": {
          "description": "One array per question, containing unique zero-based option indices. Single-select accepts exactly one. Own UI validates against its still-pending options.",
          "type": "array",
          "minItems": 1,
          "maxItems": 4,
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 4,
            "uniqueItems": true,
            "items": {
              "type": "integer",
              "minimum": 0,
              "maximum": 3
            }
          }
        },
        "expiresAt": {
          "type": "number",
          "description": "Unix epoch seconds; questions expire after 600 seconds."
        }
      }
    },
    "EventsReply": {
      "type": "object",
      "required": [
        "events",
        "lastSequence",
        "acknowledged"
      ],
      "properties": {
        "events": {
          "type": "array",
          "maxItems": 128,
          "items": {
            "oneOf": [
              {
                "$ref": "#/$defs/TapEvent"
              },
              {
                "$ref": "#/$defs/ApprovalEvent"
              },
              {
                "$ref": "#/$defs/QuestionEvent"
              }
            ]
          }
        },
        "lastSequence": {
          "type": "integer",
          "minimum": 0
        },
        "acknowledged": {
          "type": "integer",
          "minimum": 0
        }
      }
    }
  }
}
