Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/runtime/runtime-agent-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,43 @@ session's autonomy. If no session is available, it falls back to
- Events: `runtime/protocol/openphone-events.json`
- Capabilities: `runtime/protocol/openphone-capabilities.json`
- Shape reference: `runtime/protocol/openphone-runtime.schema.json`

## Versioning

Every protocol manifest carries an integer `version`. Consumers load
manifests through `runtime/protocol/openphone-runtime-tools.mjs`, which
accepts an inclusive supported range (`RUNTIME_PROTOCOL_VERSION_RANGE`,
currently `min_version=1`, `max_version=1`) instead of a single pinned
version, so new manifest versions can roll out without breaking older
clients.

### Handshake advertisement

Integrations advertise the supported runtime-protocol range in a
structured way so clients can negotiate before invoking tools:

- MCP server: the `initialize` result's `serverInfo.runtimeProtocol`
field carries `{ name, min_version, max_version }`. (This is separate
from the MCP `protocolVersion` date string, which versions the MCP
transport itself.)
- CLI: `openphone info --json` prints the same structure under
`runtime_protocol`.

### Version-bump policy

- Additive, backward-compatible changes (new commands, new optional
fields, new events/capabilities) do NOT bump the manifest version.
- Breaking changes (removing or renaming a command, changing a field's
meaning or required shape) require bumping the manifest `version` and
raising `max_version` in `RUNTIME_PROTOCOL_VERSION_RANGE`. Keep
`min_version` unchanged while old clients are still supported.
- Raise `min_version` only when support for an old manifest version is
deliberately dropped; announce it in release notes first.
- Instead of removing a command in place, mark it `deprecated: true` and
point at its replacement with `superseded_by: "<command>"`. The
validator (`runtime/protocol/validate-runtime-protocol.mjs`, run by
`scripts/check.sh`) enforces that `superseded_by` references an
existing command and is only set alongside `deprecated: true`.
Deprecated commands must keep working for at least one released
version before removal (which is the breaking change that bumps the
manifest version).
86 changes: 82 additions & 4 deletions integrations/cli/runtime/protocol/openphone-runtime-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,94 @@ import path from "node:path";
const protocolRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
const commandsPath = path.join(protocolRoot, "runtime/protocol/openphone-commands.json");

// The inclusive range of runtime-protocol manifest versions this loader
// understands. Bump max_version when a new manifest version is introduced;
// bump min_version only when support for an old version is dropped.
export const RUNTIME_PROTOCOL_VERSION_RANGE = Object.freeze({
min_version: 1,
max_version: 1,
});

export function isSupportedRuntimeProtocolVersion(
version,
range = RUNTIME_PROTOCOL_VERSION_RANGE,
) {
return Number.isInteger(version)
&& version >= range.min_version
&& version <= range.max_version;
}

export function assertSupportedRuntimeProtocolVersion(
version,
range = RUNTIME_PROTOCOL_VERSION_RANGE,
) {
if (!isSupportedRuntimeProtocolVersion(version, range)) {
throw new Error(
`unsupported runtime protocol version: ${version} `
+ `(supported range: ${range.min_version}..${range.max_version})`,
);
}
}

// Structured advertisement of the runtime-protocol version range for
// initialize-style handshakes (MCP initialize result, CLI info output).
export function runtimeProtocolInfo(range = RUNTIME_PROTOCOL_VERSION_RANGE) {
return {
name: "openphone-runtime-protocol",
min_version: range.min_version,
max_version: range.max_version,
};
}

export function validateCommandDeprecations(commands) {
const known = new Set();
for (const command of commands) {
known.add(command.name);
for (const alias of command.aliases ?? []) {
known.add(alias);
}
}
for (const command of commands) {
if (command.deprecated !== undefined && typeof command.deprecated !== "boolean") {
throw new Error(`command ${command.name}: deprecated must be a boolean`);
}
if (command.superseded_by !== undefined) {
if (typeof command.superseded_by !== "string" || !command.superseded_by) {
throw new Error(`command ${command.name}: superseded_by must be a non-empty string`);
}
if (command.deprecated !== true) {
throw new Error(`command ${command.name}: superseded_by requires deprecated=true`);
}
if (!known.has(command.superseded_by)) {
throw new Error(
`command ${command.name}: superseded_by references unknown command: `
+ command.superseded_by,
);
}
if (command.superseded_by === command.name
|| (command.aliases ?? []).includes(command.superseded_by)) {
throw new Error(`command ${command.name}: superseded_by must reference another command`);
}
}
}
return commands;
}

export function loadCommandManifest(file = commandsPath) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}

export function loadCommands(file = commandsPath) {
export function loadCommands(file = commandsPath, options = {}) {
const range = options.versionRange ?? RUNTIME_PROTOCOL_VERSION_RANGE;
const manifest = loadCommandManifest(file);
if (manifest.version !== 1 || !Array.isArray(manifest.commands)) {
throw new Error("openphone-commands.json must contain version=1 and commands[]");
if (!isSupportedRuntimeProtocolVersion(manifest.version, range)
|| !Array.isArray(manifest.commands)) {
throw new Error(
"openphone-commands.json must contain commands[] and a supported version "
+ `(range: ${range.min_version}..${range.max_version}, got: ${manifest.version})`,
);
}
return manifest.commands;
return validateCommandDeprecations(manifest.commands);
}

export function commandMap(commands = loadCommands()) {
Expand Down
15 changes: 14 additions & 1 deletion integrations/cli/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
mcpTools,
parseJsonObject,
resolveCommand,
runtimeProtocolInfo,
} from "../runtime/protocol/openphone-runtime-tools.mjs";

const commands = loadCommands();
Expand All @@ -23,7 +24,9 @@ export async function main(argv = process.argv.slice(2), io = process) {
io.stdout.write(usage());
return 0;
}
if (group === "runtime") {
if (group === "info") {
result = infoCommand();
} else if (group === "runtime") {
result = await runtimeCommand(command, rest, transport);
} else if (group === "screen" && command === "get") {
result = await toolInvoke("openphone.screen.get", flagsToToolArgs(rest), transport);
Expand Down Expand Up @@ -59,6 +62,15 @@ async function importRuntimeMcpServer() {
}
}

function infoCommand() {
return {
name: "openphone-runtime-cli",
version: "0.1.0",
runtime_protocol: runtimeProtocolInfo(),
commands: commands.length,
};
}

function runtimeCommand(command, args, transport) {
switch (command) {
case "status":
Expand Down Expand Up @@ -175,6 +187,7 @@ function usage() {
return `Usage: openphone <command> [options]

Commands:
info
runtime status
runtime list
runtime select --chat <phone|openclaw> [--volume <phone|openclaw>] [--background <phone|openclaw>]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,94 @@ import path from "node:path";
const protocolRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
const commandsPath = path.join(protocolRoot, "runtime/protocol/openphone-commands.json");

// The inclusive range of runtime-protocol manifest versions this loader
// understands. Bump max_version when a new manifest version is introduced;
// bump min_version only when support for an old version is dropped.
export const RUNTIME_PROTOCOL_VERSION_RANGE = Object.freeze({
min_version: 1,
max_version: 1,
});

export function isSupportedRuntimeProtocolVersion(
version,
range = RUNTIME_PROTOCOL_VERSION_RANGE,
) {
return Number.isInteger(version)
&& version >= range.min_version
&& version <= range.max_version;
}

export function assertSupportedRuntimeProtocolVersion(
version,
range = RUNTIME_PROTOCOL_VERSION_RANGE,
) {
if (!isSupportedRuntimeProtocolVersion(version, range)) {
throw new Error(
`unsupported runtime protocol version: ${version} `
+ `(supported range: ${range.min_version}..${range.max_version})`,
);
}
}

// Structured advertisement of the runtime-protocol version range for
// initialize-style handshakes (MCP initialize result, CLI info output).
export function runtimeProtocolInfo(range = RUNTIME_PROTOCOL_VERSION_RANGE) {
return {
name: "openphone-runtime-protocol",
min_version: range.min_version,
max_version: range.max_version,
};
}

export function validateCommandDeprecations(commands) {
const known = new Set();
for (const command of commands) {
known.add(command.name);
for (const alias of command.aliases ?? []) {
known.add(alias);
}
}
for (const command of commands) {
if (command.deprecated !== undefined && typeof command.deprecated !== "boolean") {
throw new Error(`command ${command.name}: deprecated must be a boolean`);
}
if (command.superseded_by !== undefined) {
if (typeof command.superseded_by !== "string" || !command.superseded_by) {
throw new Error(`command ${command.name}: superseded_by must be a non-empty string`);
}
if (command.deprecated !== true) {
throw new Error(`command ${command.name}: superseded_by requires deprecated=true`);
}
if (!known.has(command.superseded_by)) {
throw new Error(
`command ${command.name}: superseded_by references unknown command: `
+ command.superseded_by,
);
}
if (command.superseded_by === command.name
|| (command.aliases ?? []).includes(command.superseded_by)) {
throw new Error(`command ${command.name}: superseded_by must reference another command`);
}
}
}
return commands;
}

export function loadCommandManifest(file = commandsPath) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}

export function loadCommands(file = commandsPath) {
export function loadCommands(file = commandsPath, options = {}) {
const range = options.versionRange ?? RUNTIME_PROTOCOL_VERSION_RANGE;
const manifest = loadCommandManifest(file);
if (manifest.version !== 1 || !Array.isArray(manifest.commands)) {
throw new Error("openphone-commands.json must contain version=1 and commands[]");
if (!isSupportedRuntimeProtocolVersion(manifest.version, range)
|| !Array.isArray(manifest.commands)) {
throw new Error(
"openphone-commands.json must contain commands[] and a supported version "
+ `(range: ${range.min_version}..${range.max_version}, got: ${manifest.version})`,
);
}
return manifest.commands;
return validateCommandDeprecations(manifest.commands);
}

export function commandMap(commands = loadCommands()) {
Expand Down
8 changes: 7 additions & 1 deletion integrations/mcp-server/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import {
loadCommands,
mcpTools,
resolveCommand,
runtimeProtocolInfo,
textContent,
} from "../runtime/protocol/openphone-runtime-tools.mjs";

export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18"];
const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
const SERVER_INFO = { name: "openphone-runtime", version: "0.1.0" };
export const RUNTIME_PROTOCOL_INFO = runtimeProtocolInfo();
const SERVER_INFO = {
name: "openphone-runtime",
version: "0.1.0",
runtimeProtocol: RUNTIME_PROTOCOL_INFO,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move runtime protocol metadata out of serverInfo

For MCP clients that deserialize initialize with generated 2025-06-18 types, this nested runtimeProtocol field can be dropped because serverInfo is typed as Implementation (name, optional title, version; see https://modelcontextprotocol.io/specification/2025-06-18/schema#implementation). In that context the handshake still succeeds but the advertised OpenPhone protocol range is unavailable, defeating the negotiation this change adds; put the range on a top-level initialize extension/_meta entry or an experimental capability instead.

Useful? React with 👍 / 👎.

};

const commands = loadCommands();

Expand Down
Loading
Loading