Compare commits

...
Author SHA1 Message Date
abeatrix 62165298aa Merge branch 'main' into bee/agentphone 2026-06-03 15:35:56 -07:00
abeatrix 722f3f2d38 feat(cli): add AgentPhone connector support
Add AgentPhone as a new chat connector platform that bridges phone-based
conversations into RPC-backed Cline sessions. AgentPhone verifies its API
key at startup via `/v1/numbers`, stores the active number assigned to the
agent, and serves its webhook at `/api/webhooks/agentphone` using the
shared connector runtime formatting path.

- Add `@agentphone/chat-sdk-adapter` dependency and bump `chat` to ^4.30.0
- Implement agentphone adapter with credential parsing and HMAC webhook
  verification
- Add `cline connect agentphone` CLI command
- Update channel management UI to support editing existing connectors
- Document AgentPhone setup and runtime behavior in README and DEVELOPMENT
2026-06-03 13:39:46 -07:00
23 changed files with 2558 additions and 101 deletions
+2 -1
View File
@@ -441,12 +441,13 @@ See [DISTRIBUTION.md](./DISTRIBUTION.md) for details on how the CLI is packaged.
### Connector runtime behavior
- Telegram final assistant replies are sent through Telegram entity payloads with raw-text fallback; Google Chat and WhatsApp use the shared connector runtime formatting path.
- Telegram final assistant replies are sent through Telegram entity payloads with raw-text fallback; AgentPhone, Google Chat, and WhatsApp use the shared connector runtime formatting path.
- Assistant text streams incrementally into chat surfaces that use the shared runtime streaming path; Telegram sends final assistant replies after the turn completes.
- Tool activity is summarized as compact start/error messages with short argument previews.
- Required tool approvals are posted back into the chat thread and accept `Y` / `N` replies.
- Google Chat serves its webhook at `/api/webhooks/gchat`; configure the Google Chat App URL as `<base-url>/api/webhooks/gchat`.
- Webhook-based connectors are hosted through a shared CLI `node:http` server helper rather than `Bun.serve`.
- AgentPhone verifies startup with `/v1/numbers`, stores the active number assigned to the agent, and serves its webhook at `/api/webhooks/agentphone`; configure the AgentPhone webhook URL as `<base-url>/api/webhooks/agentphone`.
- WhatsApp serves its webhook at `/api/webhooks/whatsapp`; configure the Meta callback URL as `<base-url>/api/webhooks/whatsapp`.
## Logging adapter
+4 -1
View File
@@ -165,9 +165,12 @@ cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl ht
### Connectors
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: Telegram, Slack, Google Chat, WhatsApp, and Linear.
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: AgentPhone, Telegram, Slack, Google Chat, WhatsApp, and Linear. AgentPhone startup verifies the API key with `/v1/numbers` and stores the active phone number assigned to the agent.
```sh
# AgentPhone (webhook mode)
cline connect agentphone --api-key $AGENTPHONE_API_KEY --agent-id $AGENTPHONE_AGENT_ID --webhook-secret $AGENTPHONE_WEBHOOK_SECRET --base-url https://your-domain.com
# Telegram (polling mode)
cline connect telegram -k 123456:ABCDEF...
+3 -2
View File
@@ -68,6 +68,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentphone/chat-sdk-adapter": "^0.1.0",
"@chat-adapter/discord": "^4.23.0",
"@chat-adapter/gchat": "^4.23.0",
"@chat-adapter/linear": "^4.23.0",
@@ -80,10 +81,11 @@
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"chat": "^4.30.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"nanoid": "^5.1.7",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
@@ -91,7 +93,6 @@
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
},
"devDependencies": {
@@ -0,0 +1,469 @@
import { createHmac } from "node:crypto";
import type { ConnectAgentPhoneOptions } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import { __test__, agentPhoneConnector } from "./agentphone";
const parseAgentPhoneArgs = (rawArgs: string[]): ConnectAgentPhoneOptions =>
(
agentPhoneConnector as unknown as {
parseArgs(rawArgs: string[]): ConnectAgentPhoneOptions;
}
).parseArgs(rawArgs);
describe("agentPhoneConnector", () => {
it("parses AgentPhone credentials separately from provider credentials", () => {
const options = parseAgentPhoneArgs([
"--api-key",
"agentphone-key",
"--agent-id",
"agent_123",
"--webhook-secret",
"whsec_123",
"--provider-api-key",
"provider-key",
"--base-url",
"https://example.test",
]);
expect(options.apiKey).toBe("agentphone-key");
expect(options.agentId).toBe("agent_123");
expect(options.webhookSecret).toBe("whsec_123");
expect(options.apiProviderKey).toBe("provider-key");
expect(options.baseUrl).toBe("https://example.test");
expect(options.userName).toBeUndefined();
});
it("selects the active AgentPhone number for the configured agent", () => {
const selected = __test__.selectAgentPhoneNumber({
agentId: "agent_123",
numbers: [
{
id: "num_1",
phoneNumber: "+15550000001",
status: "inactive",
agentId: "agent_123",
},
{
id: "num_2",
phoneNumber: "+15550000002",
status: "active",
type: "sms",
agentId: "agent_123",
},
{
id: "num_3",
phoneNumber: "+15550000003",
status: "active",
agentId: "other_agent",
},
],
});
expect(selected).toMatchObject({
id: "num_2",
phoneNumber: "+15550000002",
});
});
it("verifies the AgentPhone API key by fetching the agent number", async () => {
const fetchImpl = vi.fn(async () =>
Response.json({
data: [
{
id: "cmpyckhh106qzf4t0ct7xxgye",
phoneNumber: "+18166776225",
country: "US",
status: "active",
type: "sms",
agentId: "cmpyckevf06qxf4t0y3bl2c69",
createdAt: "2026-06-03T17:35:29.654000Z",
},
],
hasMore: false,
total: 1,
}),
);
const number = await __test__.fetchAgentPhoneNumber({
apiKey: "agentphone-key",
agentId: "cmpyckevf06qxf4t0y3bl2c69",
apiUrl: "https://api.agentphone.ai/",
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://api.agentphone.ai/v1/numbers",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer agentphone-key",
}),
}),
);
expect(number).toMatchObject({
id: "cmpyckhh106qzf4t0ct7xxgye",
phoneNumber: "+18166776225",
type: "sms",
});
});
it("fails AgentPhone verification when no active number is assigned", async () => {
const fetchImpl = vi.fn(async () =>
Response.json({
data: [
{
id: "num_1",
phoneNumber: "+15550000001",
status: "active",
agentId: "other_agent",
},
],
}),
);
await expect(
__test__.fetchAgentPhoneNumber({
apiKey: "agentphone-key",
agentId: "agent_123",
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).rejects.toThrow(
"AgentPhone API verification failed: no phone number is assigned to agent agent_123.",
);
});
it("resolves inbound SMS participants from contact data", () => {
const participant = __test__.resolveAgentPhoneParticipant({
messageId: "msg_123",
conversationId: "conv_123",
numberId: "num_123",
from: "+15551234567",
to: "+15557654321",
contact: {
id: "contact_123",
name: "Alice",
email: null,
phoneNumber: "+15551234567",
},
message: "hello",
mediaUrl: null,
mediaUrls: [],
direction: "inbound",
receivedAt: "2026-03-17T00:00:00.000Z",
});
expect(participant).toEqual({
key: "agentphone:user:+15551234567",
label: "Alice",
});
});
it("normalizes iMessage email participants", () => {
const participant = __test__.resolveAgentPhoneParticipant({
messageId: "msg_123",
conversationId: "conv_123",
numberId: "num_123",
from: "Alice@Example.COM",
to: "bot@example.com",
contact: null,
message: "hello",
mediaUrl: null,
mediaUrls: [],
direction: "inbound",
receivedAt: "2026-03-17T00:00:00.000Z",
});
expect(participant?.key).toBe("agentphone:user:alice@example.com");
});
it("resolves inbound SMS message payloads into synchronous webhook turns", () => {
const turn = __test__.resolveAgentPhoneMessageTurnPayload({
event: "agent.message",
channel: "sms",
timestamp: "2026-06-03T19:19:06.000Z",
agentId: "agent_123",
data: {
messageId: "msg_123",
conversationId: "conv_123",
numberId: "num_123",
from: "+15551234567",
to: "+15557654321",
contact: null,
message: "Can Cline help?",
mediaUrl: null,
mediaUrls: [],
direction: "inbound",
receivedAt: "2026-06-03T19:19:06.000Z",
},
});
expect(turn).toMatchObject({
threadId: "agentphone:+15557654321:+15551234567",
text: "Can Cline help?",
rawMessage: {
messageId: "msg_123",
conversationId: "conv_123",
from: "+15551234567",
to: "+15557654321",
message: "Can Cline help?",
direction: "inbound",
},
});
});
it("formats AgentPhone request errors for webhook responses", () => {
expect(
__test__.formatAgentPhoneRequestError(
new Error(
"Auth failed: Outbound SMS is not enabled for this account. Complete 10DLC registration first.",
),
),
).toBe(
"Request failed. Outbound SMS is not enabled for this account. Complete 10DLC registration first.",
);
});
it("returns JSON acknowledgements for non-JSON AgentPhone adapter responses", async () => {
const response = await __test__.normalizeAgentPhoneWebhookResponse({
response: new Response("OK", { status: 200 }),
payload: {
event: "agent.message",
channel: "sms",
},
});
expect(response.headers.get("content-type")).toContain("application/json");
await expect(response.json()).resolves.toEqual({ ok: true });
});
it("posts non-voice AgentPhone replies through the thread", async () => {
const post = vi.fn(async () => undefined);
const logger = {
core: {
log: vi.fn(),
error: vi.fn(),
},
};
const io = { writeln: vi.fn() };
await __test__.postAgentPhoneReply({
thread: {
id: "agentphone:+15557654321:+15551234567",
channelId: "agentphone:+15557654321",
post,
} as unknown as Parameters<
typeof __test__.postAgentPhoneReply
>[0]["thread"],
text: " hello from cline ",
logger: logger as unknown as Parameters<
typeof __test__.postAgentPhoneReply
>[0]["logger"],
io: io as unknown as Parameters<
typeof __test__.postAgentPhoneReply
>[0]["io"],
});
expect(post).toHaveBeenCalledWith("hello from cline");
expect(logger.core.log).toHaveBeenCalledWith(
"AgentPhone outbound reply sent",
expect.objectContaining({
outputLength: "hello from cline".length,
}),
);
expect(logger.core.error).not.toHaveBeenCalled();
});
it("surfaces outbound AgentPhone reply send failures", async () => {
const error = new Error("AgentPhone API rejected the message");
const post = vi.fn(async () => {
throw error;
});
const logger = {
core: {
log: vi.fn(),
error: vi.fn(),
},
};
const io = { writeln: vi.fn() };
await expect(
__test__.postAgentPhoneReply({
thread: {
id: "agentphone:+15557654321:+15551234567",
channelId: "agentphone:+15557654321",
post,
} as unknown as Parameters<
typeof __test__.postAgentPhoneReply
>[0]["thread"],
text: "hello from cline",
logger: logger as unknown as Parameters<
typeof __test__.postAgentPhoneReply
>[0]["logger"],
io: io as unknown as Parameters<
typeof __test__.postAgentPhoneReply
>[0]["io"],
}),
).rejects.toThrow("AgentPhone API rejected the message");
expect(logger.core.error).toHaveBeenCalledWith(
"AgentPhone outbound reply failed",
expect.objectContaining({
error,
}),
);
});
it("returns voice text for non-JSON voice webhook responses", async () => {
const response = await __test__.normalizeAgentPhoneWebhookResponse({
response: new Response("OK", { status: 200 }),
payload: {
event: "agent.message",
channel: "voice",
},
});
await expect(response.json()).resolves.toEqual({
text: "Cline is connected. I will process the call transcript when this call ends.",
});
});
it("preserves non-OK webhook response status as JSON", async () => {
const response = await __test__.normalizeAgentPhoneWebhookResponse({
response: new Response("Invalid signature", { status: 401 }),
payload: undefined,
});
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({
error: "Invalid signature",
});
});
it("streams interim and final voice responses as NDJSON", async () => {
const response = __test__.agentPhoneVoiceResponse(
async () => "The answer is 42.",
);
expect(response.headers.get("content-type")).toContain(
"application/x-ndjson",
);
const lines = (await response.text())
.trim()
.split("\n")
.map((line) => JSON.parse(line) as unknown);
expect(lines).toEqual([
{ text: "One moment, let me check.", interim: true },
{ text: "The answer is 42." },
]);
});
it("resolves AgentPhone live voice message payloads into turn input", () => {
const turn = __test__.resolveAgentPhoneVoiceTurnPayload({
event: "agent.message",
channel: "voice",
timestamp: "2026-06-03T19:19:06.000Z",
agentId: "agent_123",
data: {
conversationId: "conv_123",
from: "+15551234567",
to: "+15557654321",
message: "What is my order status?",
direction: "inbound",
receivedAt: "2026-06-03T19:19:06.000Z",
},
});
expect(turn).toMatchObject({
threadId: "agentphone:+15557654321:+15551234567",
text: "What is my order status?",
rawMessage: {
conversationId: "conv_123",
from: "+15551234567",
to: "+15557654321",
message: "What is my order status?",
direction: "inbound",
},
});
});
it("classifies voice lifecycle webhooks separately from voice message turns", () => {
expect(
__test__.isAgentPhoneVoiceMessagePayload({
event: "agent.call.started",
channel: "voice",
data: {},
}),
).toBe(false);
expect(
__test__.resolveAgentPhoneVoiceTurnPayload({
event: "agent.call.started",
channel: "voice",
data: {},
}),
).toBeUndefined();
});
it("verifies AgentPhone webhook signatures against the raw body", () => {
const rawBody = JSON.stringify({ event: "agent.message" });
const secret = "whsec_test";
const signature = `sha256=${createHmac("sha256", secret)
.update(rawBody)
.digest("hex")}`;
expect(
__test__.verifyAgentPhoneWebhookSignature({
rawBody,
secret,
signature,
}),
).toBe(true);
expect(
__test__.verifyAgentPhoneWebhookSignature({
rawBody,
secret,
signature: "sha256=bad",
}),
).toBe(false);
});
it("rejects AgentPhone webhook signatures when no secret is configured", () => {
expect(
__test__.verifyAgentPhoneWebhookSignature({
rawBody: JSON.stringify({ event: "agent.message" }),
secret: undefined,
signature: "sha256=abcd",
}),
).toBe(false);
});
});
describe("agentphone binding lookup", () => {
it("reuses a binding by participant key across different threads", () => {
const result = __test__.findBindingForThread(
{
"agentphone:user:+15551234567": {
channelId: "agentphone:+15557654321",
isDM: true,
participantKey: "agentphone:user:+15551234567",
participantLabel: "Alice",
serializedThread: "{}",
sessionId: "sess-1",
state: {
sessionId: "sess-1",
participantKey: "agentphone:user:+15551234567",
participantLabel: "Alice",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
},
{
id: "agentphone:+15557654321:+15551234567",
channelId: "agentphone:+15557654321",
isDM: true,
participantKey: "agentphone:user:+15551234567",
},
);
expect(result?.key).toBe("agentphone:user:+15551234567");
expect(result?.binding.sessionId).toBe("sess-1");
});
});
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -11,7 +11,13 @@ import type {
ConnectDiscordOptions,
DiscordConnectorState,
} from "@cline/shared";
import { Chat, ConsoleLogger, type Thread, ThreadImpl } from "chat";
import {
type Adapter,
Chat,
ConsoleLogger,
type Thread,
ThreadImpl,
} from "chat";
import type { Command } from "commander";
import { createCliLoggerAdapter } from "../../logging/adapter";
import {
@@ -1040,7 +1046,7 @@ class DiscordConnector extends ConnectorBase<
}) as DiscordAdapter;
const bot = new Chat({
userName: options.userName,
adapters: { discord },
adapters: { discord: discord as unknown as Adapter },
state: new InMemoryStateAdapter(),
logger,
fallbackStreamingPlaceholderText: null,
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
ConnectGoogleChatOptions,
GoogleChatConnectorState,
} from "@cline/shared";
import { Chat, ConsoleLogger, type Thread } from "chat";
import { type Adapter, Chat, ConsoleLogger, type Thread } from "chat";
import type { Command } from "commander";
import type { CliLoggerAdapter } from "../../logging/adapter";
import { createCliLoggerAdapter } from "../../logging/adapter";
@@ -514,7 +514,7 @@ class GoogleChatConnector extends ConnectorBase<
);
const bot = new Chat({
userName: options.userName,
adapters: { gchat },
adapters: { gchat: gchat as unknown as Adapter },
state: new InMemoryStateAdapter(),
logger,
fallbackStreamingPlaceholderText: null,
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
ConnectTelegramOptions,
TelegramConnectorState,
} from "@cline/shared";
import { Chat, ConsoleLogger, type Thread } from "chat";
import { type Adapter, Chat, ConsoleLogger, type Thread } from "chat";
import type { Command } from "commander";
import type { CliLoggerAdapter } from "../../logging/adapter";
import { createCliLoggerAdapter } from "../../logging/adapter";
@@ -653,7 +653,7 @@ class TelegramConnector extends ConnectorBase<
});
const bot = new Chat({
userName: options.botUsername,
adapters: { telegram },
adapters: { telegram: telegram as unknown as Adapter },
state: new InMemoryStateAdapter(),
logger,
fallbackStreamingPlaceholderText: null,
+2 -2
View File
@@ -8,7 +8,7 @@ import type {
ConnectWhatsAppOptions,
WhatsAppConnectorState,
} from "@cline/shared";
import { Chat, ConsoleLogger, type Thread } from "chat";
import { type Adapter, Chat, ConsoleLogger, type Thread } from "chat";
import type { Command } from "commander";
import type { CliLoggerAdapter } from "../../logging/adapter";
import { createCliLoggerAdapter } from "../../logging/adapter";
@@ -513,7 +513,7 @@ class WhatsAppConnector extends ConnectorBase<
const whatsapp = createWhatsAppAdapter(whatsappConfig);
const bot = new Chat({
userName: options.userName,
adapters: { whatsapp },
adapters: { whatsapp: whatsapp as unknown as Adapter },
state: new InMemoryStateAdapter(),
logger,
fallbackStreamingPlaceholderText: null,
+5
View File
@@ -4,6 +4,11 @@ export type ConnectorCatalogEntry = {
};
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
{
name: "agentphone",
description:
"AgentPhone SMS, MMS, iMessage, and voice webhook bridge backed by RPC runtime sessions",
},
{
name: "discord",
description:
+45 -47
View File
@@ -85,6 +85,28 @@ async function editConnectorText(
return await message.edit(connectorTextPayload(transport, text));
}
function createCapturedConnectorMessage(
onEdit: (text: string) => Promise<SentMessage>,
): SentMessage {
const message = {
edit: async (text: string) => onEdit(text),
delete: async () => undefined,
addReaction: async () => undefined,
removeReaction: async () => undefined,
} as unknown as SentMessage;
return message;
}
async function captureConnectorText(
sink: (text: string) => Promise<void>,
text: string,
): Promise<SentMessage> {
await sink(text);
return createCapturedConnectorMessage((nextText) =>
captureConnectorText(sink, nextText),
);
}
function buildAttachments(input: {
userImages: string[];
userFiles: string[];
@@ -259,6 +281,7 @@ export async function handleConnectorUserTurn<
baseStartRequest: ChatStartSessionRequest,
thread: Thread<TState>,
) => Promise<string> | string;
replyTextSink?: (text: string) => Promise<void>;
postFinalReply?: (input: {
thread: Thread<TState>;
text: string;
@@ -282,6 +305,17 @@ export async function handleConnectorUserTurn<
return;
}
const runtimeInput = input.runtimeText?.trim() || resolvedInput;
const postText = async (text: string): Promise<SentMessage> =>
input.replyTextSink
? captureConnectorText(input.replyTextSink, text)
: postConnectorText(input.thread, input.transport, text);
const editText = async (
message: SentMessage,
text: string,
): Promise<SentMessage> =>
input.replyTextSink
? captureConnectorText(input.replyTextSink, text)
: editConnectorText(message, input.transport, text);
const initialState = await loadThreadState(
input.thread,
@@ -326,7 +360,7 @@ export async function handleConnectorUserTurn<
const denialMessage =
authorization.message?.trim() ||
"You are not authorized to use this bot.";
await postConnectorText(input.thread, input.transport, denialMessage);
await postText(denialMessage);
await dispatchConnectorHook(
input.hookCommand,
{
@@ -385,11 +419,7 @@ export async function handleConnectorUserTurn<
input.ownerParticipantKeys,
)
) {
await postConnectorText(
input.thread,
input.transport,
"Only the connector owner can use slash commands.",
);
await postText("Only the connector owner can use slash commands.");
input.logger.core.log("Non-owner connector chat command denied", {
transport: input.transport,
threadId: input.thread.id,
@@ -439,11 +469,7 @@ export async function handleConnectorUserTurn<
: input.firstContactMessage;
let initialStateChanged = false;
if (!initialState.welcomeSentAt && firstContactMessage?.trim()) {
await postConnectorText(
input.thread,
input.transport,
firstContactMessage.trim(),
);
await postText(firstContactMessage.trim());
initialState.welcomeSentAt = new Date().toISOString();
initialStateChanged = true;
}
@@ -482,11 +508,7 @@ export async function handleConnectorUserTurn<
const toolLockCommand = commandName?.match(/^\/(tools|yolo)$/i);
if (input.forceDisableTools && toolLockCommand) {
const settingName = toolLockCommand[1]?.toLowerCase();
await postConnectorText(
input.thread,
input.transport,
`${settingName}=off (disabled by connector startup)`,
);
await postText(`${settingName}=off (disabled by connector startup)`);
return;
}
@@ -578,7 +600,7 @@ export async function handleConnectorUserTurn<
);
},
reply: async (message) => {
await postConnectorText(input.thread, input.transport, message);
await postText(message);
},
reset: async () => {
await clearSession({
@@ -612,19 +634,11 @@ export async function handleConnectorUserTurn<
abort: async () => {
const activeTurn = input.activeTurns?.get(turnKey);
if (!activeTurn?.sessionId?.trim()) {
await postConnectorText(
input.thread,
input.transport,
"No active task to abort.",
);
await postText("No active task to abort.");
return;
}
await input.client.abortRuntimeSession(activeTurn.sessionId);
await postConnectorText(
input.thread,
input.transport,
"Aborting current task.",
);
await postText("Aborting current task.");
},
mute: async (commandInput: MuteCommandInput) => {
const target = commandInput.target?.trim()
@@ -931,11 +945,7 @@ export async function handleConnectorUserTurn<
},
{ timeoutMs: null },
);
await postConnectorText(
input.thread,
input.transport,
"Steering current task.",
);
await postText("Steering current task.");
return;
}
const sessionId = await getOrCreateSessionId({
@@ -997,26 +1007,14 @@ export async function handleConnectorUserTurn<
conversationId: input.thread.id,
onToolStatus: async (message) => {
if (toolStatusMessage) {
toolStatusMessage = await editConnectorText(
toolStatusMessage,
input.transport,
message,
);
toolStatusMessage = await editText(toolStatusMessage, message);
return;
}
toolStatusMessage = await postConnectorText(
input.thread,
input.transport,
message,
);
toolStatusMessage = await postText(message);
},
onApprovalRequested: async (approval) => {
input.pendingApprovals.set(input.thread.id, approval);
await postConnectorText(
input.thread,
input.transport,
formatConnectorApprovalPrompt(approval),
);
await postText(formatConnectorApprovalPrompt(approval));
},
onCompleted: async (result) => {
await input.onReplyCompleted?.({
+9
View File
@@ -12,6 +12,15 @@ const connectorDescriptions = new Map(
);
const registry = new Map<string, ConnectorRegistryEntry>([
[
"agentphone",
{
name: "agentphone",
description: connectorDescriptions.get("agentphone") ?? "AgentPhone",
load: async () =>
(await import("./adapters/agentphone")).agentPhoneConnector,
},
],
[
"discord",
{
+28
View File
@@ -20,10 +20,15 @@ export type ActiveConnectorRecord = {
pid: number;
hubUrl: string;
startedAt?: string;
agentId?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
agentPhoneNumber?: string;
phoneNumberId?: string;
phoneNumberCountry?: string;
phoneNumberStatus?: string;
phoneNumberType?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
@@ -72,18 +77,40 @@ const connectorFieldExtractors: Record<
connectionMode: (p) =>
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
agentPhoneNumber: (p) =>
typeof p.agentPhoneNumber === "string" ? p.agentPhoneNumber : undefined,
botUsername: (p) =>
typeof p.botUsername === "string" ? p.botUsername : undefined,
applicationId: (p) =>
typeof p.applicationId === "string" ? p.applicationId : undefined,
agentId: (p) => (typeof p.agentId === "string" ? p.agentId : undefined),
phoneNumberId: (p) =>
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
phoneNumberCountry: (p) =>
typeof p.phoneNumberCountry === "string" ? p.phoneNumberCountry : undefined,
phoneNumberStatus: (p) =>
typeof p.phoneNumberStatus === "string" ? p.phoneNumberStatus : undefined,
phoneNumberType: (p) =>
typeof p.phoneNumberType === "string" ? p.phoneNumberType : undefined,
};
const connectorConfigs: Record<
string,
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
> = {
agentphone: {
required: ["userName", "agentId"],
optional: [
"startedAt",
"port",
"baseUrl",
"agentPhoneNumber",
"phoneNumberId",
"phoneNumberCountry",
"phoneNumberStatus",
"phoneNumberType",
],
},
discord: {
required: ["userName", "applicationId"],
optional: ["startedAt", "port", "baseUrl"],
@@ -169,6 +196,7 @@ function readActiveConnectorRecord(
export function listActiveConnectors(): ActiveConnectorRecord[] {
const connectorTypes: ActiveConnectorRecord["type"][] = [
"agentphone",
"discord",
"telegram",
"gchat",
@@ -10,9 +10,15 @@ describe("connect wizard platform security fields", () => {
});
it("rejects unsafe Telegram and Slack access restriction identifiers", () => {
const agentphone = PLATFORMS.find(
(platform) => platform.id === "agentphone",
);
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
const slack = PLATFORMS.find((platform) => platform.id === "slack");
const agentphoneParticipant = agentphone?.security?.fields.find(
(field) => field.key === "participant",
);
const telegramUser = telegram?.security?.fields.find(
(field) => field.key === "userId",
);
@@ -23,6 +29,13 @@ describe("connect wizard platform security fields", () => {
(field) => field.key === "userId",
);
expect(agentphoneParticipant?.validate?.("+15551234567")).toBeUndefined();
expect(
agentphoneParticipant?.validate?.("alice@example.com"),
).toBeUndefined();
expect(agentphoneParticipant?.validate?.("alice; rm -rf /")).toContain(
"AgentPhone participant",
);
expect(telegramUser?.validate?.("123456")).toBeUndefined();
expect(telegramUser?.validate?.("123; rm -rf /")).toContain("digits");
expect(slackTeam?.validate?.("T01ABC123")).toBeUndefined();
+65
View File
@@ -75,7 +75,72 @@ function validateSlackUserId(value: string): string | undefined {
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
}
function validateAgentPhoneParticipant(value: string): string | undefined {
return /^[+@._a-zA-Z0-9-]+$/.test(value)
? undefined
: "AgentPhone participant must contain only letters, digits, +, @, ., _, or -";
}
export const PLATFORMS: PlatformDef[] = [
{
id: "agentphone",
name: "AgentPhone",
type: "webhook",
hint: "SMS, MMS, iMessage, and voice call transcripts.",
fields: [
{
flag: "--api-key",
label: "API key",
required: true,
help: ["Create or copy an AgentPhone API key"],
},
{
flag: "--agent-id",
label: "Agent ID",
placeholder: "The AgentPhone agent ID to send messages from.",
required: true,
help: ["The AgentPhone agent ID to send messages from."],
},
{
flag: "--base-url",
label: "Cline Webhook URL",
placeholder: "https://example.com",
required: true,
help: [
"Your publicly accessible URL for webhook callbacks from Cline",
"Set the Agent Webhook in AgentPhone's Agents setting to <base-url>/api/webhooks/agentphone",
"Use the returned Signing Secret to configure the --webhook-secret field below",
],
},
{
flag: "--webhook-secret",
label: "Webhook signing secret",
required: true,
help: [
"The signing secret associated with the AgentPhone's Agent Webhook",
],
},
],
security: {
prompt: "Restrict which AgentPhone sender can interact with the bot?",
fields: [
{
key: "participant",
label: "Allowed phone or email",
placeholder: "+15551234567",
help: [
"Use the sender phone number or iMessage email address",
"The value is matched against agentphone:user:<value>",
],
requiredMessage:
"Phone number or email is required to restrict access",
validate: validateAgentPhoneParticipant,
},
],
buildHookCommand: ({ participant }) =>
`jq -r ".payload.actor.participantKey" | grep -F -q "agentphone:user:${participant}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
},
},
{
id: "telegram",
name: "Telegram",
+39
View File
@@ -169,6 +169,45 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export async function updateConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const stopResult = await runCliConnectCommand([channel, "--stop"]);
if (stopResult.code !== 0) {
throw new Error(
(
stopResult.stderr.trim() ||
stopResult.stdout.trim() ||
"connector stop failed"
)
.trim()
.slice(0, 2_000),
);
}
await waitForConnectorState(
() =>
!listActiveConnectors().some((connector) => connector.type === channel),
);
const startResult = await runCliConnectCommand(cliArgs);
if (startResult.code !== 0) {
throw new Error(
(
startResult.stderr.trim() ||
startResult.stdout.trim() ||
"connector update failed"
)
.trim()
.slice(0, 2_000),
);
}
await waitForConnectorState(() =>
listActiveConnectors().some((connector) => connector.type === channel),
);
return connectorChannelsPayload();
}
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
@@ -25,6 +25,7 @@ import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
updateConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
@@ -168,6 +169,11 @@ export async function handleDesktopCommand(
broadcastHubState(ctx);
return response;
}
if (command === "update_connector_channel") {
const response = await updateConnectorChannel(args);
broadcastHubState(ctx);
return response;
}
if (command === "list_mcp_servers") {
return readMcpServersResponse();
}
+5
View File
@@ -169,10 +169,15 @@ export type WebviewActiveConnector = {
pid: number;
hubUrl: string;
startedAt?: string;
agentId?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
agentPhoneNumber?: string;
phoneNumberId?: string;
phoneNumberCountry?: string;
phoneNumberStatus?: string;
phoneNumberType?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
+15 -1
View File
@@ -210,6 +210,9 @@ function connectorLabel(connector: WebviewActiveConnector): string {
if (connector.botUsername) {
return `@${connector.botUsername}`;
}
if (connector.agentPhoneNumber) {
return connector.agentPhoneNumber;
}
if (connector.userName) {
return connector.userName;
}
@@ -219,6 +222,17 @@ function connectorLabel(connector: WebviewActiveConnector): string {
return shortId(connector.id);
}
function connectorDetails(connector: WebviewActiveConnector): string {
return [
connectorLabel(connector),
connector.agentId ? `agent=${connector.agentId}` : undefined,
connector.phoneNumberType ? `type=${connector.phoneNumberType}` : undefined,
`pid=${connector.pid}`,
]
.filter((detail): detail is string => Boolean(detail))
.join(" | ");
}
function sessionRunDetails(session: WebviewSessionSummary): string[] {
const inputTokens = formatCompactNumber(session.inputTokens);
const outputTokens = formatCompactNumber(session.outputTokens);
@@ -534,7 +548,7 @@ function HomeView({
className="block truncate text-[11px] text-muted-foreground"
title={connector.hubUrl}
>
{connectorLabel(connector)} | pid={connector.pid}
{connectorDetails(connector)}
</span>
</div>
<span
@@ -1,6 +1,6 @@
"use client";
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Circle, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertDialog,
@@ -77,10 +77,15 @@ type ActiveConnector = {
pid: number;
hubUrl: string;
startedAt?: string;
agentId?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
agentPhoneNumber?: string;
phoneNumberId?: string;
phoneNumberCountry?: string;
phoneNumberStatus?: string;
phoneNumberType?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
@@ -98,6 +103,8 @@ type ConnectorFormState = {
securityValues: Record<string, string>;
};
type ConnectorFormMode = "add" | "edit";
function connectorName(
connector: ActiveConnector,
channels: ConnectorChannel[],
@@ -112,6 +119,9 @@ function connectorIdentity(connector: ActiveConnector): string {
if (connector.botUsername) {
return `@${connector.botUsername}`;
}
if (connector.agentPhoneNumber) {
return connector.agentPhoneNumber;
}
if (connector.userName) {
return connector.userName;
}
@@ -121,6 +131,30 @@ function connectorIdentity(connector: ActiveConnector): string {
return `pid ${connector.pid}`;
}
function connectorDetailBadges(connector: ActiveConnector): string[] {
return [
connector.agentId ? `agent=${connector.agentId}` : undefined,
connector.applicationId ? `app=${connector.applicationId}` : undefined,
connector.agentPhoneNumber
? `phone=${connector.agentPhoneNumber}`
: undefined,
connector.phoneNumberType ? `type=${connector.phoneNumberType}` : undefined,
connector.phoneNumberStatus
? `status=${connector.phoneNumberStatus}`
: undefined,
connector.phoneNumberId ? `numberId=${connector.phoneNumberId}` : undefined,
connector.port ? `port=${connector.port}` : undefined,
`pid=${connector.pid}`,
].filter((detail): detail is string => Boolean(detail));
}
function connectorWebhookUrl(connector: ActiveConnector): string | undefined {
if (!connector.baseUrl || connector.type === "telegram") {
return undefined;
}
return `${connector.baseUrl.replace(/\/$/, "")}/api/webhooks/${connector.type}`;
}
function formatDateTime(value?: string): string {
if (!value) {
return "-";
@@ -190,6 +224,50 @@ function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
};
}
function connectorFieldValue(
connector: ActiveConnector,
flag: string,
): string | undefined {
if (flag === "--agent-id") {
return connector.agentId;
}
if (flag === "--application-id") {
return connector.applicationId;
}
if (flag === "--phone-number-id") {
return connector.phoneNumberId;
}
if (flag === "--base-url") {
return connector.baseUrl;
}
if (flag === "--user-name") {
return connector.userName;
}
if (flag === "--bot-username") {
return connector.botUsername;
}
return undefined;
}
function createEditFormState(
connector: ActiveConnector,
channel: ConnectorChannel,
): ConnectorFormState {
const values: Record<string, string> = {};
for (const field of channel.fields) {
const value = connectorFieldValue(connector, field.flag);
if (value) {
values[field.flag] = value;
}
}
return {
channelId: channel.id,
values,
securityEnabled: false,
securityValues: {},
};
}
export function ChannelsContent() {
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
@@ -199,6 +277,8 @@ export function ChannelsContent() {
const [busyChannel, setBusyChannel] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [formMode, setFormMode] = useState<ConnectorFormMode>("add");
const [editTarget, setEditTarget] = useState<ActiveConnector | null>(null);
const [formState, setFormState] = useState<ConnectorFormState>({
channelId: "",
values: {},
@@ -256,11 +336,26 @@ export function ChannelsContent() {
}, [refreshChannels]);
const openAddDialog = () => {
setFormMode("add");
setEditTarget(null);
setFormState(createFormState(channels));
setFormError(null);
setDialogOpen(true);
};
const openEditDialog = (connector: ActiveConnector) => {
const channel = channels.find((entry) => entry.id === connector.type);
if (!channel) {
setErrorMessage(`Unknown connector channel: ${connector.type}`);
return;
}
setFormMode("edit");
setEditTarget(connector);
setFormState(createEditFormState(connector, channel));
setFormError(null);
setDialogOpen(true);
};
const updateFieldValue = (flag: string, value: string) => {
setFormState((prev) => ({
...prev,
@@ -275,7 +370,7 @@ export function ChannelsContent() {
}));
};
const startConnector = async () => {
const saveConnector = async () => {
if (!selectedChannel) {
setFormError("Choose a channel");
return;
@@ -302,9 +397,12 @@ export function ChannelsContent() {
setErrorMessage(null);
try {
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
"start_connector_channel",
formMode === "edit"
? "update_connector_channel"
: "start_connector_channel",
{
channel: selectedChannel.id,
connectorId: editTarget?.id,
values: formState.values,
security: {
enabled: formState.securityEnabled,
@@ -314,6 +412,8 @@ export function ChannelsContent() {
);
applyResponse(response);
setDialogOpen(false);
setEditTarget(null);
setFormMode("add");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setFormError(message);
@@ -407,21 +507,34 @@ export function ChannelsContent() {
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
{connectorDetailBadges(connector).map((detail) => (
<span
className="rounded-md border bg-background px-1.5 py-0.5"
key={detail}
>
{detail}
</span>
))}
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
hub={connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
base={connector.baseUrl}
</span>
) : null}
{connectorWebhookUrl(connector) ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connectorWebhookUrl(connector)}
>
webhook={connectorWebhookUrl(connector)}
</span>
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
@@ -434,16 +547,26 @@ export function ChannelsContent() {
) : null}
</div>
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
<div className="flex items-center gap-2">
<Button
disabled={busyChannel === connector.type}
onClick={() => openEditDialog(connector)}
size="sm"
type="button"
variant="outline"
>
<Pencil className="size-4" />
</Button>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))
)}
@@ -451,18 +574,32 @@ export function ChannelsContent() {
</section>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<Dialog
open={dialogOpen}
onOpenChange={(open: boolean) => {
setDialogOpen(open);
if (!open) {
setEditTarget(null);
setFormMode("add");
}
}}
>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add Channel</DialogTitle>
<DialogTitle>
{formMode === "edit" ? "Edit Channel" : "Add Channel"}
</DialogTitle>
<DialogDescription>
Start a connector channel for Cline Hub.
{formMode === "edit"
? "Update this connector channel and restart it with the new settings."
: "Start a connector channel for Cline Hub."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Channel</Label>
<Select
disabled={formMode === "edit"}
onValueChange={(value) => {
if (!value) {
return;
@@ -491,6 +628,13 @@ export function ChannelsContent() {
</Select>
</div>
{formMode === "edit" ? (
<div className="rounded-md border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Stored runtime fields are prefilled. Secret fields are not read
back from the running connector, so re-enter them before saving.
</div>
) : null}
{visibleFields.map((field) => (
<div className="grid gap-2" key={field.flag}>
<Label>
@@ -540,6 +684,13 @@ export function ChannelsContent() {
value={formState.values[field.flag] ?? ""}
/>
)}
{field.help?.length ? (
<div className="grid gap-1 text-xs text-muted-foreground">
{field.help.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
</div>
))}
@@ -572,6 +723,13 @@ export function ChannelsContent() {
type={isSecretField(field) ? "password" : "text"}
value={formState.securityValues[field.key] ?? ""}
/>
{field.help?.length ? (
<div className="grid gap-1 text-xs text-muted-foreground">
{field.help.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
</div>
))
: null}
@@ -595,10 +753,16 @@ export function ChannelsContent() {
</Button>
<Button
disabled={busyChannel !== null || !selectedChannel}
onClick={() => void startConnector()}
onClick={() => void saveConnector()}
type="button"
>
{busyChannel ? "Starting..." : "Add Channel"}
{busyChannel
? formMode === "edit"
? "Saving..."
: "Starting..."
: formMode === "edit"
? "Save Changes"
: "Add Channel"}
</Button>
</DialogFooter>
</DialogContent>
+20 -17
View File
@@ -25,6 +25,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@agentphone/chat-sdk-adapter": "^0.1.0",
"@chat-adapter/discord": "^4.23.0",
"@chat-adapter/gchat": "^4.23.0",
"@chat-adapter/linear": "^4.23.0",
@@ -37,7 +38,7 @@
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"chat": "^4.23.0",
"chat": "^4.30.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
@@ -464,6 +465,8 @@
"packages": {
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@agentphone/chat-sdk-adapter": ["@agentphone/chat-sdk-adapter@0.1.0", "", { "dependencies": { "@chat-adapter/shared": "^4.20.0" }, "peerDependencies": { "chat": "^4.20.0" } }, "sha512-cal0EcWdbROsBT2cFd4TPxDM6SggdxifOkGCI/YFrmWapXSUgdA3wXTsZ3C8Z9Pcs1Zlg8ShNf2JSOqkvfHIWw=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cH71k96tcnLuq1x3xi0KP384Jxio8qM6VQzHDUfU4OuX2P83FC/pBvksR5YVRm17GdQliAfR1t5o6z1iJRtfpA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
@@ -520,8 +523,6 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1058.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/eventstream-handler-node": "^3.972.18", "@aws-sdk/middleware-eventstream": "^3.972.14", "@aws-sdk/middleware-websocket": "^3.972.23", "@aws-sdk/token-providers": "3.1058.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-p2co5KPnRLByP8VwLJIdNM7TFwa68ZXZJjPWCovIHsJ8L/oN83Oze4/gv3UHO+fKYI4Ve3M3N3KKSH6aTsXTSw=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1056.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.46", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Fywg6+B39uGiYZRYFEsOXbIeHQ8wvtMqlt6FUwWev8N2H+V0pVdgCKn32pSOzud1i17wnm5gpB2VXZEoyVHc2A=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
@@ -546,17 +547,11 @@
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1056.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.45", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Qp7ndCG+dZldiaURze6BM/dLkHQJxwi6WNRR1sR9lhX9jS9QG5ZIOiY3jm6T668vgGqHuNQS7r/P9pimxnHyyg=="],
"@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.18", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg=="],
"@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg=="],
"@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.23", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1058.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-m22Usagf/HzN8Nlktz3Lt/IukBZl/JYBkhlpMHowXl2xtcXpcGuh59vtltC1Uy26pR86Ez2EqwVLl8eOZP6v3A=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
@@ -1794,7 +1789,7 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"chat": ["chat@4.30.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-8LXrauKckMmR83FcYC/R8nNEda5VJDDdIhZwUUu+hzaSbk4lqsro0IWm7rB1GGYXONRrUOG2XJlkNr4C15vgMA=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
@@ -3284,12 +3279,22 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@chat-adapter/discord/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@chat-adapter/gchat/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@chat-adapter/linear/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@chat-adapter/shared/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@chat-adapter/slack/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@chat-adapter/telegram/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@chat-adapter/whatsapp/chat": ["chat@4.29.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "zod"] }, "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw=="],
"@cline/cline-hub-webview/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@cline/code/lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
@@ -3696,8 +3701,6 @@
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@aws-sdk/client-bedrock-runtime/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
"@cline/cline-hub-webview/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
+37 -1
View File
@@ -1,7 +1,7 @@
---
title: "Connectors"
sidebarTitle: "Connectors"
description: "Connect the CLI to Telegram, Slack, Discord, Google Chat, WhatsApp, etc."
description: "Connect the CLI to AgentPhone, Telegram, Slack, Discord, Google Chat, WhatsApp, etc."
---
<Warning>
This feature currently only applies to Cline CLI.
@@ -21,6 +21,7 @@ cline connect
| Platform | Direct Command | Required Credentials |
|----------|---------------|---------------------|
| AgentPhone | `cline connect agentphone` | API key, agent ID, webhook signing secret, base URL |
| Telegram | `cline connect telegram` | Bot token |
| Slack | `cline connect slack` | Bot token plus webhook signing secret/base URL or socket app token |
| Discord | `cline connect discord` | Application ID, bot token, public key, base URL |
@@ -73,6 +74,41 @@ By default, anyone who finds your bot can message it and it will execute tasks o
The `--hook-command` receives each incoming message with sender info via stdin. Your script returns `{"action": "allow"}` or `{"action": "deny", "message": "reason"}`. Without `--hook-command`, everything is auto-approved.
## AgentPhone
Requires an AgentPhone API key, agent ID, webhook signing secret, and public base URL. Startup verifies the API key by calling AgentPhone's `/v1/numbers` endpoint and stores the active phone number assigned to the agent.
```bash
cline connect agentphone \
--api-key <KEY> \
--agent-id <AGENT-ID> \
--webhook-secret <SECRET> \
--base-url <URL>
```
AgentPhone handles SMS, MMS, iMessage, and completed voice call transcripts through one webhook.
Configure the webhook URL in AgentPhone as:
```text
<base-url>/api/webhooks/agentphone
```
Inbound messages and completed voice call transcripts map to Cline sessions by sender identity, so the same phone number or iMessage address continues the same agent session.
For live voice webhook requests, the connector returns a JSON spoken acknowledgment immediately; Cline processes the transcript event after the call ends.
### AgentPhone Security
By default, anyone who can message your AgentPhone number can ask it to run tasks. Restrict access with `--hook-command`. The hook receives participant keys such as `agentphone:user:+15551234567`.
```bash
cline connect agentphone \
--api-key <KEY> \
--agent-id <AGENT-ID> \
--webhook-secret <SECRET> \
--base-url <URL> \
--hook-command 'jq -r ".payload.actor.participantKey" | grep -F -q "agentphone:user:+15551234567" && echo "{\"action\":\"allow\"}" || echo "{\"action\":\"deny\",\"message\":\"unauthorized\"}"'
```
## Slack
Slack supports webhook mode and socket mode. Each Slack thread maps to an agent session, so the agent maintains conversation context within a thread.
@@ -32,6 +32,44 @@ export type WhatsAppConnectorState = {
startedAt: string;
};
export type ConnectAgentPhoneOptions = {
userName?: string;
apiKey: string;
agentId: string;
webhookSecret?: string;
apiUrl?: string;
cwd: string;
model?: string;
provider?: string;
apiProviderKey?: string;
systemPrompt?: string;
mode: "act" | "plan";
interactive: boolean;
maxIterations?: number;
enableTools: boolean;
rpcAddress: string;
hookCommand?: string;
port: number;
host: string;
baseUrl: string;
};
export type AgentPhoneConnectorState = {
instanceKey: string;
userName: string;
agentId: string;
agentPhoneNumber: string;
phoneNumberId: string;
phoneNumberCountry?: string;
phoneNumberStatus?: string;
phoneNumberType?: string;
pid: number;
rpcAddress: string;
port: number;
baseUrl: string;
startedAt: string;
};
export type ConnectTelegramOptions = {
botToken: string;
botUsername?: string;