---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index d51cbb50a..18ea6ca16 100644
--- a/README.md
+++ b/README.md
@@ -452,6 +452,8 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
+Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
+
Thanks to all clawtributors:
From 581da8020916a17620813ac88bc66739674f8d7c Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:13:55 +0100
Subject: [PATCH 014/220] chore: log gateway reload and signals
---
src/agents/tools/gateway-tool.ts | 3 +
src/cli/gateway-cli.ts | 187 ++++++++++++++++++++++++++++++-
src/gateway/config-reload.ts | 3 +
src/macos/gateway-daemon.ts | 15 ++-
4 files changed, 202 insertions(+), 6 deletions(-)
diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts
index 036e91d63..83d0571c5 100644
--- a/src/agents/tools/gateway-tool.ts
+++ b/src/agents/tools/gateway-tool.ts
@@ -71,6 +71,9 @@ export function createGatewayTool(opts?: {
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
+ console.info(
+ `gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`,
+ );
const scheduled = scheduleGatewaySigusr1Restart({
delayMs,
reason,
diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts
index 4c3e49757..ed853cb08 100644
--- a/src/cli/gateway-cli.ts
+++ b/src/cli/gateway-cli.ts
@@ -22,13 +22,17 @@ import {
setGatewayWsLogStyle,
} from "../gateway/ws-logging.js";
import { setVerbose } from "../globals.js";
+import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
+import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
+import { WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
import {
createSubsystemLogger,
setConsoleSubsystemFilter,
} from "../logging.js";
import { defaultRuntime } from "../runtime.js";
+import { colorize, isRich, theme } from "../terminal/theme.js";
import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
@@ -87,6 +91,103 @@ const toOptionString = (value: unknown): string | undefined => {
return undefined;
};
+type GatewayDiscoverOpts = {
+ timeout?: string;
+ json?: boolean;
+};
+
+function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number {
+ if (raw === undefined || raw === null) return fallbackMs;
+ const value = typeof raw === "string" ? raw.trim() : String(raw);
+ if (!value) return fallbackMs;
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ throw new Error(`invalid --timeout: ${value}`);
+ }
+ return parsed;
+}
+
+function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
+ const host = beacon.tailnetDns || beacon.lanHost || beacon.host;
+ return host?.trim() ? host.trim() : null;
+}
+
+function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
+ const port = beacon.gatewayPort ?? 18789;
+ return port > 0 ? port : 18789;
+}
+
+function dedupeBeacons(
+ beacons: GatewayBonjourBeacon[],
+): GatewayBonjourBeacon[] {
+ const out: GatewayBonjourBeacon[] = [];
+ const seen = new Set();
+ for (const b of beacons) {
+ const host = pickBeaconHost(b) ?? "";
+ const key = [
+ b.domain ?? "",
+ b.instanceName ?? "",
+ b.displayName ?? "",
+ host,
+ String(b.port ?? ""),
+ String(b.bridgePort ?? ""),
+ String(b.gatewayPort ?? ""),
+ ].join("|");
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(b);
+ }
+ return out;
+}
+
+function renderBeaconLines(
+ beacon: GatewayBonjourBeacon,
+ rich: boolean,
+): string[] {
+ const nameRaw = (
+ beacon.displayName ||
+ beacon.instanceName ||
+ "Gateway"
+ ).trim();
+ const domainRaw = (beacon.domain || "local.").trim();
+
+ const title = colorize(rich, theme.accentBright, nameRaw);
+ const domain = colorize(rich, theme.muted, domainRaw);
+
+ const parts: string[] = [];
+ if (beacon.tailnetDns)
+ parts.push(
+ `${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`,
+ );
+ if (beacon.lanHost)
+ parts.push(`${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`);
+ if (beacon.host)
+ parts.push(`${colorize(rich, theme.info, "host")}: ${beacon.host}`);
+
+ const host = pickBeaconHost(beacon);
+ const gatewayPort = pickGatewayPort(beacon);
+ const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
+
+ const firstLine =
+ parts.length > 0
+ ? `${title} ${domain} · ${parts.join(" · ")}`
+ : `${title} ${domain}`;
+
+ const lines = [`- ${firstLine}`];
+ if (wsUrl) {
+ lines.push(
+ ` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
+ );
+ }
+ if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
+ const ssh = `ssh -N -L 18789:127.0.0.1:18789 @${host} -p ${beacon.sshPort}`;
+ lines.push(
+ ` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`,
+ );
+ }
+ return lines;
+}
+
function describeUnknownError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
@@ -219,9 +320,18 @@ async function runGatewayLoop(params: {
})();
};
- const onSigterm = () => request("stop", "SIGTERM");
- const onSigint = () => request("stop", "SIGINT");
- const onSigusr1 = () => request("restart", "SIGUSR1");
+ const onSigterm = () => {
+ gatewayLog.info("signal SIGTERM received");
+ request("stop", "SIGTERM");
+ };
+ const onSigint = () => {
+ gatewayLog.info("signal SIGINT received");
+ request("stop", "SIGINT");
+ };
+ const onSigusr1 = () => {
+ gatewayLog.info("signal SIGUSR1 received");
+ request("restart", "SIGUSR1");
+ };
process.on("SIGTERM", onSigterm);
process.on("SIGINT", onSigint);
@@ -658,4 +768,75 @@ export function registerGatewayCli(program: Command) {
}
}),
);
+
+ gateway
+ .command("discover")
+ .description(
+ `Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
+ )
+ .option("--timeout ", "Per-command timeout in ms", "2000")
+ .option("--json", "Output JSON", false)
+ .action(async (opts: GatewayDiscoverOpts) => {
+ try {
+ const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
+ const beacons = await withProgress(
+ {
+ label: "Scanning for gateways…",
+ indeterminate: true,
+ enabled: opts.json !== true,
+ },
+ async () => await discoverGatewayBeacons({ timeoutMs }),
+ );
+
+ const deduped = dedupeBeacons(beacons).sort((a, b) =>
+ String(a.displayName || a.instanceName).localeCompare(
+ String(b.displayName || b.instanceName),
+ ),
+ );
+
+ if (opts.json) {
+ const enriched = deduped.map((b) => {
+ const host = pickBeaconHost(b);
+ const port = pickGatewayPort(b);
+ return {
+ ...b,
+ wsUrl: host ? `ws://${host}:${port}` : null,
+ };
+ });
+ defaultRuntime.log(
+ JSON.stringify(
+ {
+ timeoutMs,
+ domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
+ count: enriched.length,
+ beacons: enriched,
+ },
+ null,
+ 2,
+ ),
+ );
+ return;
+ }
+
+ const rich = isRich();
+ defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
+ defaultRuntime.log(
+ colorize(
+ rich,
+ theme.muted,
+ `Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
+ ),
+ );
+ if (deduped.length === 0) return;
+
+ for (const beacon of deduped) {
+ for (const line of renderBeaconLines(beacon, rich)) {
+ defaultRuntime.log(line);
+ }
+ }
+ } catch (err) {
+ defaultRuntime.error(`gateway discover failed: ${String(err)}`);
+ defaultRuntime.exit(1);
+ }
+ });
}
diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts
index 6b998cbe5..72b459d4e 100644
--- a/src/gateway/config-reload.ts
+++ b/src/gateway/config-reload.ts
@@ -308,6 +308,9 @@ export function startGatewayConfigReloader(opts: {
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) return;
+ opts.log.info(
+ `config change detected; evaluating reload (${changedPaths.join(", ")})`,
+ );
const plan = buildGatewayReloadPlan(changedPaths);
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts
index 2f499b245..47eb85154 100644
--- a/src/macos/gateway-daemon.ts
+++ b/src/macos/gateway-daemon.ts
@@ -149,9 +149,18 @@ async function main() {
})();
};
- const onSigterm = () => request("stop", "SIGTERM");
- const onSigint = () => request("stop", "SIGINT");
- const onSigusr1 = () => request("restart", "SIGUSR1");
+ const onSigterm = () => {
+ defaultRuntime.log("gateway: signal SIGTERM received");
+ request("stop", "SIGTERM");
+ };
+ const onSigint = () => {
+ defaultRuntime.log("gateway: signal SIGINT received");
+ request("stop", "SIGINT");
+ };
+ const onSigusr1 = () => {
+ defaultRuntime.log("gateway: signal SIGUSR1 received");
+ request("restart", "SIGUSR1");
+ };
process.on("SIGTERM", onSigterm);
process.on("SIGINT", onSigint);
From c807c7e72d89735569aed8536338e64a5cd9e14b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:16:16 +0100
Subject: [PATCH 015/220] chore: clarify multi-agent reporting
---
AGENTS.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/AGENTS.md b/AGENTS.md
index 8b4566da4..42088930c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,6 +83,7 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
+- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
From 2062165cd356182f650ae33da212419f10aebbd7 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:19:58 +0100
Subject: [PATCH 016/220] chore: add configure section hints
---
src/commands/configure.ts | 236 ++++++++++++++++++++++++++++++--------
1 file changed, 191 insertions(+), 45 deletions(-)
diff --git a/src/commands/configure.ts b/src/commands/configure.ts
index 08bf0598a..67371e9a7 100644
--- a/src/commands/configure.ts
+++ b/src/commands/configure.ts
@@ -11,7 +11,6 @@ import {
text,
} from "@clack/prompts";
import {
- loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -20,7 +19,10 @@ import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
+ upsertAuthProfile,
} from "../agents/auth-profiles.js";
+import { normalizeProviderId } from "../agents/model-selection.js";
+import { parseDurationMs } from "../cli/parse-duration.js";
import { createCliProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
@@ -302,6 +304,7 @@ async function promptAuthConfig(
) as
| "oauth"
| "claude-cli"
+ | "token"
| "openai-codex"
| "codex-cli"
| "antigravity"
@@ -314,50 +317,165 @@ async function promptAuthConfig(
if (authChoice === "oauth") {
note(
- "Browser will open. Paste the code shown after login (code#state).",
- "Anthropic OAuth",
+ [
+ "This will run `claude setup-token` to create a long-lived Anthropic token.",
+ "Requires an interactive TTY and a Claude Pro/Max subscription.",
+ ].join("\n"),
+ "Anthropic token",
);
- const spin = startOscSpinner("Waiting for authorization…");
- let oauthCreds: OAuthCredentials | null = null;
- try {
- oauthCreds = await loginAnthropic(
- async (url) => {
- await openUrl(url);
- runtime.log(`Open: ${url}`);
- },
- async () => {
- const code = guardCancel(
- await text({
- message: "Paste authorization code (code#state)",
- validate: (value) => (value?.trim() ? undefined : "Required"),
- }),
- runtime,
- );
- return String(code);
- },
+
+ if (!process.stdin.isTTY) {
+ note(
+ "`claude setup-token` requires an interactive TTY.",
+ "Anthropic token",
);
- spin.stop("OAuth complete");
- if (oauthCreds) {
- await writeOAuthCredentials("anthropic", oauthCreds);
- const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
- next = applyAuthProfileConfig(next, {
- profileId,
- provider: "anthropic",
- mode: "oauth",
- email: oauthCreds.email ?? undefined,
- });
- }
- } catch (err) {
- spin.stop("OAuth failed");
- runtime.error(String(err));
- note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth");
+ return next;
}
- } else if (authChoice === "claude-cli") {
+
+ const proceed = guardCancel(
+ await confirm({
+ message: "Run `claude setup-token` now?",
+ initialValue: true,
+ }),
+ runtime,
+ );
+ if (!proceed) return next;
+
+ const res = await (async () => {
+ const { spawnSync } = await import("node:child_process");
+ return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
+ })();
+ if (res.error) {
+ note(`Failed to run claude: ${String(res.error)}`, "Anthropic token");
+ return next;
+ }
+ if (typeof res.status === "number" && res.status !== 0) {
+ note(`claude setup-token failed (exit ${res.status})`, "Anthropic token");
+ return next;
+ }
+
+ const store = ensureAuthProfileStore(undefined, {
+ allowKeychainPrompt: true,
+ });
+ if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
+ note(
+ `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
+ "Anthropic token",
+ );
+ return next;
+ }
+
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
- mode: "oauth",
+ mode: "token",
});
+ } else if (authChoice === "claude-cli") {
+ const store = ensureAuthProfileStore(undefined, {
+ allowKeychainPrompt: false,
+ });
+ if (!store.profiles[CLAUDE_CLI_PROFILE_ID] && process.stdin.isTTY) {
+ note(
+ [
+ "No Claude CLI credentials found yet.",
+ "If you have a Claude Pro/Max subscription, run `claude setup-token`.",
+ ].join("\n"),
+ "Claude CLI",
+ );
+ const runNow = guardCancel(
+ await confirm({
+ message: "Run `claude setup-token` now?",
+ initialValue: true,
+ }),
+ runtime,
+ );
+ if (runNow) {
+ const res = await (async () => {
+ const { spawnSync } = await import("node:child_process");
+ return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
+ })();
+ if (res.error) {
+ note(
+ `Failed to run claude: ${String(res.error)}`,
+ "Claude setup-token",
+ );
+ }
+ }
+ }
+ next = applyAuthProfileConfig(next, {
+ profileId: CLAUDE_CLI_PROFILE_ID,
+ provider: "anthropic",
+ mode: "token",
+ });
+ } else if (authChoice === "token") {
+ const providerRaw = guardCancel(
+ await text({
+ message: "Token provider id (e.g. anthropic)",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ runtime,
+ );
+ const provider = normalizeProviderId(String(providerRaw).trim());
+ const defaultProfileId = `${provider}:manual`;
+ const profileIdRaw = guardCancel(
+ await text({
+ message: "Auth profile id",
+ initialValue: defaultProfileId,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ runtime,
+ );
+ const profileId = String(profileIdRaw).trim();
+
+ const tokenRaw = guardCancel(
+ await text({
+ message: `Paste token for ${provider}`,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ runtime,
+ );
+ const token = String(tokenRaw).trim();
+
+ const wantsExpiry = guardCancel(
+ await confirm({
+ message: "Does this token expire?",
+ initialValue: false,
+ }),
+ runtime,
+ );
+ const expiresInRaw = wantsExpiry
+ ? guardCancel(
+ await text({
+ message: "Expires in (duration)",
+ initialValue: "365d",
+ validate: (value) => {
+ try {
+ parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
+ return undefined;
+ } catch {
+ return "Invalid duration (e.g. 365d, 12h, 30m)";
+ }
+ },
+ }),
+ runtime,
+ )
+ : "";
+ const expiresIn = String(expiresInRaw).trim();
+ const expires = expiresIn
+ ? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
+ : undefined;
+
+ upsertAuthProfile({
+ profileId,
+ credential: {
+ type: "token",
+ provider,
+ token,
+ ...(expires ? { expires } : {}),
+ },
+ });
+
+ next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" });
} else if (authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
note(
@@ -787,13 +905,41 @@ export async function runConfigureWizard(
await multiselect({
message: "Select sections to configure",
options: [
- { value: "workspace", label: "Workspace" },
- { value: "model", label: "Model/auth" },
- { value: "gateway", label: "Gateway config" },
- { value: "daemon", label: "Gateway daemon" },
- { value: "providers", label: "Providers" },
- { value: "skills", label: "Skills" },
- { value: "health", label: "Health check" },
+ {
+ value: "workspace",
+ label: "Workspace",
+ hint: "Set agent workspace + ensure sessions",
+ },
+ {
+ value: "model",
+ label: "Model/auth",
+ hint: "Pick model + auth profile sources",
+ },
+ {
+ value: "gateway",
+ label: "Gateway config",
+ hint: "Port/bind/auth/control UI settings",
+ },
+ {
+ value: "daemon",
+ label: "Gateway daemon",
+ hint: "Install/manage the background service",
+ },
+ {
+ value: "providers",
+ label: "Providers",
+ hint: "Link WhatsApp/Telegram/etc and defaults",
+ },
+ {
+ value: "skills",
+ label: "Skills",
+ hint: "Install/enable workspace skills",
+ },
+ {
+ value: "health",
+ label: "Health check",
+ hint: "Run gateway + provider checks",
+ },
],
}),
runtime,
From 266643bb9484fed86c408b173aadf31ff8306c3f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:31:00 +0100
Subject: [PATCH 017/220] feat(gateway): discover on local + wide-area DNS-SD
---
src/cli/gateway-cli.coverage.test.ts | 56 +++++++++++
src/infra/bonjour-discovery.test.ts | 134 +++++++++++++++++++++++++++
src/infra/bonjour-discovery.ts | 69 ++++++++++----
3 files changed, 243 insertions(+), 16 deletions(-)
create mode 100644 src/infra/bonjour-discovery.test.ts
diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts
index c7b28d6a1..98d52db62 100644
--- a/src/cli/gateway-cli.coverage.test.ts
+++ b/src/cli/gateway-cli.coverage.test.ts
@@ -12,6 +12,7 @@ const forceFreePortAndWait = vi.fn(async () => ({
escalatedToSigkill: false,
}));
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
+const discoverGatewayBeacons = vi.fn(async () => []);
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -90,6 +91,10 @@ vi.mock("../daemon/program-args.js", () => ({
}),
}));
+vi.mock("../infra/bonjour-discovery.js", () => ({
+ discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
+}));
+
describe("gateway-cli coverage", () => {
it("registers call/health/status commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
@@ -110,6 +115,57 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
});
+ it("registers gateway discover and prints JSON", async () => {
+ runtimeLogs.length = 0;
+ runtimeErrors.length = 0;
+ discoverGatewayBeacons.mockReset();
+ discoverGatewayBeacons.mockResolvedValueOnce([
+ {
+ instanceName: "Studio (Clawdbot)",
+ displayName: "Studio",
+ domain: "local.",
+ host: "studio.local",
+ lanHost: "studio.local",
+ tailnetDns: "studio.tailnet.ts.net",
+ gatewayPort: 18789,
+ bridgePort: 18790,
+ sshPort: 22,
+ },
+ ]);
+
+ const { registerGatewayCli } = await import("./gateway-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerGatewayCli(program);
+
+ await program.parseAsync(["gateway", "discover", "--json"], { from: "user" });
+
+ expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
+ expect(runtimeLogs.join("\n")).toContain('"beacons"');
+ expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
+ expect(runtimeLogs.join("\n")).toContain("ws://");
+ });
+
+ it("validates gateway discover timeout", async () => {
+ runtimeLogs.length = 0;
+ runtimeErrors.length = 0;
+ discoverGatewayBeacons.mockReset();
+
+ const { registerGatewayCli } = await import("./gateway-cli.js");
+ const program = new Command();
+ program.exitOverride();
+ registerGatewayCli(program);
+
+ await expect(
+ program.parseAsync(["gateway", "discover", "--timeout", "0"], {
+ from: "user",
+ }),
+ ).rejects.toThrow("__exit__:1");
+
+ expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
+ expect(discoverGatewayBeacons).not.toHaveBeenCalled();
+ });
+
it("fails gateway call on invalid params JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts
new file mode 100644
index 000000000..353a0dab4
--- /dev/null
+++ b/src/infra/bonjour-discovery.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { runCommandWithTimeout } from "../process/exec.js";
+import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
+import { discoverGatewayBeacons } from "./bonjour-discovery.js";
+
+describe("bonjour-discovery", () => {
+ it("discovers beacons on darwin across local + wide-area domains", async () => {
+ const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
+
+ const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
+ calls.push({ argv, timeoutMs: options.timeoutMs });
+ const domain = argv[3] ?? "";
+
+ if (argv[0] === "dns-sd" && argv[1] === "-B") {
+ if (domain === "local.") {
+ return {
+ stdout: [
+ "Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
+ "Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
+ "",
+ ].join("\n"),
+ stderr: "",
+ code: 0,
+ signal: null,
+ killed: false,
+ };
+ }
+ if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
+ return {
+ stdout: [
+ `Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
+ "",
+ ].join("\n"),
+ stderr: "",
+ code: 0,
+ signal: null,
+ killed: false,
+ };
+ }
+ }
+
+ if (argv[0] === "dns-sd" && argv[1] === "-L") {
+ const instance = argv[2] ?? "";
+ const host =
+ instance === "Studio Bridge"
+ ? "studio.local"
+ : instance === "Laptop Bridge"
+ ? "laptop.local"
+ : "tailnet.local";
+ const tailnetDns =
+ instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
+ const txtParts = [
+ "txtvers=1",
+ `displayName=${instance.replace(" Bridge", "")}`,
+ `lanHost=${host}`,
+ "gatewayPort=18789",
+ "bridgePort=18790",
+ "sshPort=22",
+ tailnetDns ? `tailnetDns=${tailnetDns}` : null,
+ ].filter((v): v is string => Boolean(v));
+
+ return {
+ stdout: [
+ `${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
+ txtParts.join(" "),
+ "",
+ ].join("\n"),
+ stderr: "",
+ code: 0,
+ signal: null,
+ killed: false,
+ };
+ }
+
+ throw new Error(`unexpected argv: ${argv.join(" ")}`);
+ });
+
+ const beacons = await discoverGatewayBeacons({
+ platform: "darwin",
+ timeoutMs: 1234,
+ run: run as unknown as typeof runCommandWithTimeout,
+ });
+
+ expect(beacons).toHaveLength(3);
+ expect(beacons.map((b) => b.domain)).toEqual(
+ expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
+ );
+
+ const browseCalls = calls.filter(
+ (c) => c.argv[0] === "dns-sd" && c.argv[1] === "-B",
+ );
+ expect(browseCalls.map((c) => c.argv[3])).toEqual(
+ expect.arrayContaining(["local.", WIDE_AREA_DISCOVERY_DOMAIN]),
+ );
+ expect(browseCalls.every((c) => c.timeoutMs === 1234)).toBe(true);
+ });
+
+ it("normalizes domains and respects domains override", async () => {
+ const calls: string[][] = [];
+ const run = vi.fn(async (argv: string[]) => {
+ calls.push(argv);
+ return {
+ stdout: "",
+ stderr: "",
+ code: 0,
+ signal: null,
+ killed: false,
+ };
+ });
+
+ await discoverGatewayBeacons({
+ platform: "darwin",
+ timeoutMs: 1,
+ domains: ["local", "clawdbot.internal"],
+ run: run as unknown as typeof runCommandWithTimeout,
+ });
+
+ expect(calls.filter((c) => c[1] === "-B").map((c) => c[3])).toEqual(
+ expect.arrayContaining(["local.", "clawdbot.internal."]),
+ );
+
+ calls.length = 0;
+ await discoverGatewayBeacons({
+ platform: "darwin",
+ timeoutMs: 1,
+ domains: ["local."],
+ run: run as unknown as typeof runCommandWithTimeout,
+ });
+
+ expect(calls.filter((c) => c[1] === "-B")).toHaveLength(1);
+ expect(calls.filter((c) => c[1] === "-B")[0]?.[3]).toBe("local.");
+ });
+});
diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts
index a6ae02f41..1dfba3492 100644
--- a/src/infra/bonjour-discovery.ts
+++ b/src/infra/bonjour-discovery.ts
@@ -1,7 +1,9 @@
import { runCommandWithTimeout } from "../process/exec.js";
+import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
export type GatewayBonjourBeacon = {
instanceName: string;
+ domain?: string;
displayName?: string;
host?: string;
port?: number;
@@ -16,10 +18,15 @@ export type GatewayBonjourBeacon = {
export type GatewayBonjourDiscoverOpts = {
timeoutMs?: number;
+ domains?: string[];
+ platform?: NodeJS.Platform;
+ run?: typeof runCommandWithTimeout;
};
const DEFAULT_TIMEOUT_MS = 2000;
+const DEFAULT_DOMAINS = ["local.", WIDE_AREA_DISCOVERY_DOMAIN] as const;
+
function parseIntOrNull(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
@@ -94,21 +101,22 @@ function parseDnsSdResolve(
}
async function discoverViaDnsSd(
+ domain: string,
timeoutMs: number,
+ run: typeof runCommandWithTimeout,
): Promise {
- const browse = await runCommandWithTimeout(
- ["dns-sd", "-B", "_clawdbot-bridge._tcp", "local."],
- { timeoutMs },
- );
+ const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], {
+ timeoutMs,
+ });
const instances = parseDnsSdBrowse(browse.stdout);
const results: GatewayBonjourBeacon[] = [];
for (const instance of instances) {
- const resolved = await runCommandWithTimeout(
- ["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", "local."],
+ const resolved = await run(
+ ["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain],
{ timeoutMs },
);
const parsed = parseDnsSdResolve(resolved.stdout, instance);
- if (parsed) results.push(parsed);
+ if (parsed) results.push({ ...parsed, domain });
}
return results;
}
@@ -168,25 +176,54 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
}
async function discoverViaAvahi(
+ domain: string,
timeoutMs: number,
+ run: typeof runCommandWithTimeout,
): Promise {
- const browse = await runCommandWithTimeout(
- ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"],
- { timeoutMs },
- );
- return parseAvahiBrowse(browse.stdout);
+ const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
+ if (domain && domain !== "local.") {
+ // avahi-browse wants a plain domain (no trailing dot)
+ args.push("-d", domain.replace(/\.$/, ""));
+ }
+ const browse = await run(args, { timeoutMs });
+ return parseAvahiBrowse(browse.stdout).map((beacon) => ({
+ ...beacon,
+ domain,
+ }));
}
export async function discoverGatewayBeacons(
opts: GatewayBonjourDiscoverOpts = {},
): Promise {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const platform = opts.platform ?? process.platform;
+ const run = opts.run ?? runCommandWithTimeout;
+ const domainsRaw = Array.isArray(opts.domains) ? opts.domains : [];
+ const domains = (domainsRaw.length > 0 ? domainsRaw : [...DEFAULT_DOMAINS])
+ .map((d) => String(d).trim())
+ .filter(Boolean)
+ .map((d) => (d.endsWith(".") ? d : `${d}.`));
+
try {
- if (process.platform === "darwin") {
- return await discoverViaDnsSd(timeoutMs);
+ if (platform === "darwin") {
+ const perDomain = await Promise.allSettled(
+ domains.map(
+ async (domain) => await discoverViaDnsSd(domain, timeoutMs, run),
+ ),
+ );
+ return perDomain.flatMap((r) =>
+ r.status === "fulfilled" ? r.value : [],
+ );
}
- if (process.platform === "linux") {
- return await discoverViaAvahi(timeoutMs);
+ if (platform === "linux") {
+ const perDomain = await Promise.allSettled(
+ domains.map(
+ async (domain) => await discoverViaAvahi(domain, timeoutMs, run),
+ ),
+ );
+ return perDomain.flatMap((r) =>
+ r.status === "fulfilled" ? r.value : [],
+ );
}
} catch {
return [];
From 8808d8c84cfc90049d115aab48ddc24f57256c7e Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:51:27 +0100
Subject: [PATCH 018/220] docs: add gateway CLI doc
---
docs/cli/gateway.md | 107 ++++++++++++++++++++++++++++++++++++++++++++
docs/cli/index.md | 1 +
docs/docs.json | 7 +++
3 files changed, 115 insertions(+)
create mode 100644 docs/cli/gateway.md
diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md
new file mode 100644
index 000000000..960bbd2c1
--- /dev/null
+++ b/docs/cli/gateway.md
@@ -0,0 +1,107 @@
+---
+summary: "Clawdbot Gateway CLI (`clawdbot gateway`) — run, query, and discover gateways"
+read_when:
+ - Running the Gateway from the CLI (dev or servers)
+ - Debugging Gateway auth, bind modes, and connectivity
+ - Discovering gateways via Bonjour (LAN + tailnet)
+---
+
+# Gateway CLI
+
+The Gateway is Clawdbot’s WebSocket server (providers, nodes, sessions, hooks).
+
+Subcommands in this page live under `clawdbot gateway …`.
+
+Related docs:
+- [/gateway/bonjour](/gateway/bonjour)
+- [/gateway/discovery](/gateway/discovery)
+- [/gateway/configuration](/gateway/configuration)
+
+## Run the Gateway
+
+Run a local Gateway process:
+
+```bash
+clawdbot gateway
+```
+
+Notes:
+- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
+- Binding beyond loopback without auth is blocked (safety guardrail).
+- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
+
+### Options
+
+- `--port `: WebSocket port (default comes from config/env; usually `18789`).
+- `--bind `: listener bind mode.
+- `--auth `: auth mode override.
+- `--token `: token override (also sets `CLAWDBOT_GATEWAY_TOKEN` for the process).
+- `--password `: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
+- `--tailscale `: expose the Gateway via Tailscale.
+- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
+- `--force`: kill any existing listener on the selected port before starting.
+- `--verbose`: verbose logs.
+- `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr).
+- `--ws-log `: websocket log style (default `auto`).
+- `--compact`: alias for `--ws-log compact`.
+- `--raw-stream`: log raw model stream events to jsonl.
+- `--raw-stream-path `: raw stream jsonl path.
+
+## Query a running Gateway
+
+All query commands use WebSocket RPC.
+
+Shared options:
+- `--url `: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured).
+- `--token `: Gateway token (if required).
+- `--password `: Gateway password (password auth).
+- `--timeout `: timeout (default `10000`).
+- `--expect-final`: wait for a “final” response (agent calls).
+
+### `gateway health`
+
+```bash
+clawdbot gateway health --url ws://127.0.0.1:18789
+```
+
+### `gateway status`
+
+```bash
+clawdbot gateway status --url ws://127.0.0.1:18789
+```
+
+### `gateway call `
+
+Low-level RPC helper.
+
+```bash
+clawdbot gateway call status
+clawdbot gateway call logs.tail --params '{"sinceMs": 60000}'
+```
+
+## Discover gateways (Bonjour)
+
+`gateway discover` scans for Gateway bridge beacons (`_clawdbot-bridge._tcp`).
+
+- Multicast DNS-SD: `local.`
+- Unicast DNS-SD (Wide-Area Bonjour): `clawdbot.internal.` (requires split DNS + DNS server; see [/gateway/bonjour](/gateway/bonjour))
+
+Only gateways with the **bridge enabled** will advertise the discovery beacon.
+
+### `gateway discover`
+
+```bash
+clawdbot gateway discover
+```
+
+Options:
+- `--timeout `: per-command timeout (browse/resolve); default `2000`.
+- `--json`: machine-readable output (also disables styling/spinner).
+
+Examples:
+
+```bash
+clawdbot gateway discover --timeout 4000
+clawdbot gateway discover --json | jq '.beacons[].wsUrl'
+```
+
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 0b70f2b8e..99fb75d3c 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -70,6 +70,7 @@ clawdbot [--dev] [--profile ]
call
health
status
+ discover
models
list
status
diff --git a/docs/docs.json b/docs/docs.json
index b74b08aab..e7737bf55 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -549,6 +549,13 @@
"install/bun"
]
},
+ {
+ "group": "CLI",
+ "pages": [
+ "cli/index",
+ "cli/gateway"
+ ]
+ },
{
"group": "Core Concepts",
"pages": [
From af1f6fab292eca9b7e3e8996797ee746d0db33cd Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:51:32 +0100
Subject: [PATCH 019/220] chore: add lobster CLI banner art
---
src/cli/banner.ts | 35 +++++++++++++++++++++++++++++++++++
src/cli/program.ts | 15 +++++++++++----
2 files changed, 46 insertions(+), 4 deletions(-)
diff --git a/src/cli/banner.ts b/src/cli/banner.ts
index 3153d5a81..670a1cddc 100644
--- a/src/cli/banner.ts
+++ b/src/cli/banner.ts
@@ -33,6 +33,41 @@ export function formatCliBannerLine(
return `${title} ${version} (${commitLabel}) — ${tagline}`;
}
+const LOBSTER_ASCII = [
+ "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
+ "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
+ "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
+ "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
+ "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
+ " 🦞 FRESH DAILY 🦞",
+];
+
+export function formatCliBannerArt(options: BannerOptions = {}): string {
+ const rich = options.richTty ?? isRich();
+ if (!rich) return LOBSTER_ASCII.join("\n");
+
+ const colorChar = (ch: string) => {
+ if (ch === "█") return theme.accentBright(ch);
+ if (ch === "░") return theme.accentDim(ch);
+ if (ch === "▀") return theme.accent(ch);
+ return theme.muted(ch);
+ };
+
+ const colored = LOBSTER_ASCII.map((line) => {
+ if (line.includes("FRESH DAILY")) {
+ return (
+ theme.muted(" ") +
+ theme.accent("🦞") +
+ theme.info(" FRESH DAILY ") +
+ theme.accent("🦞")
+ );
+ }
+ return [...line].map(colorChar).join("");
+ });
+
+ return colored.join("\n");
+}
+
export function emitCliBanner(version: string, options: BannerOptions = {}) {
if (bannerEmitted) return;
const argv = options.argv ?? process.argv;
diff --git a/src/cli/program.ts b/src/cli/program.ts
index 1ab75732f..1b8f20675 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -25,7 +25,11 @@ import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { defaultRuntime } from "../runtime.js";
import { isRich, theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
-import { emitCliBanner, formatCliBannerLine } from "./banner.js";
+import {
+ emitCliBanner,
+ formatCliBannerArt,
+ formatCliBannerLine,
+} from "./banner.js";
import { registerBrowserCli } from "./browser-cli.js";
import { hasExplicitOptions } from "./command-options.js";
import { registerCronCli } from "./cron-cli.js";
@@ -95,8 +99,10 @@ export function buildProgram() {
}
program.addHelpText("beforeAll", () => {
- const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: isRich() });
- return `\n${line}\n`;
+ const rich = isRich();
+ const art = formatCliBannerArt({ richTty: rich });
+ const line = formatCliBannerLine(PROGRAM_VERSION, { richTty: rich });
+ return `\n${art}\n${line}\n`;
});
program.hook("preAction", async (_thisCommand, actionCommand) => {
@@ -231,7 +237,7 @@ export function buildProgram() {
.option("--mode ", "Wizard mode: local|remote")
.option(
"--auth-choice ",
- "Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
+ "Auth: oauth|claude-cli|token|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip",
)
.option("--anthropic-api-key ", "Anthropic API key")
.option("--gemini-api-key ", "Gemini API key")
@@ -260,6 +266,7 @@ export function buildProgram() {
authChoice: opts.authChoice as
| "oauth"
| "claude-cli"
+ | "token"
| "openai-codex"
| "codex-cli"
| "antigravity"
From eced473e0515eee45cd70741bdfac639fcbb3aba Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:51:37 +0100
Subject: [PATCH 020/220] feat: add models auth commands
---
src/cli/models-cli.ts | 62 ++++++++++
src/cli/parse-duration.test.ts | 4 +
src/cli/parse-duration.ts | 21 +++-
src/commands/models.ts | 5 +
src/commands/models/auth.ts | 207 +++++++++++++++++++++++++++++++++
5 files changed, 295 insertions(+), 4 deletions(-)
create mode 100644 src/commands/models/auth.ts
diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts
index 85d7d8149..ac3f5342d 100644
--- a/src/cli/models-cli.ts
+++ b/src/cli/models-cli.ts
@@ -4,6 +4,9 @@ import {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
+ modelsAuthAddCommand,
+ modelsAuthPasteTokenCommand,
+ modelsAuthSetupTokenCommand,
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
@@ -294,4 +297,63 @@ export function registerModelsCli(program: Command) {
defaultRuntime.exit(1);
}
});
+
+ const auth = models.command("auth").description("Manage model auth profiles");
+
+ auth
+ .command("add")
+ .description("Interactive auth helper (setup-token or paste token)")
+ .action(async () => {
+ try {
+ await modelsAuthAddCommand({}, defaultRuntime);
+ } catch (err) {
+ defaultRuntime.error(String(err));
+ defaultRuntime.exit(1);
+ }
+ });
+
+ auth
+ .command("setup-token")
+ .description("Run a provider CLI to create/sync a token (TTY required)")
+ .option("--provider ", "Provider id (default: anthropic)")
+ .option("--yes", "Skip confirmation", false)
+ .action(async (opts) => {
+ try {
+ await modelsAuthSetupTokenCommand(
+ {
+ provider: opts.provider as string | undefined,
+ yes: Boolean(opts.yes),
+ },
+ defaultRuntime,
+ );
+ } catch (err) {
+ defaultRuntime.error(String(err));
+ defaultRuntime.exit(1);
+ }
+ });
+
+ auth
+ .command("paste-token")
+ .description("Paste a token into auth-profiles.json and update config")
+ .requiredOption("--provider ", "Provider id (e.g. anthropic)")
+ .option("--profile-id ", "Auth profile id (default: :manual)")
+ .option(
+ "--expires-in ",
+ "Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.",
+ )
+ .action(async (opts) => {
+ try {
+ await modelsAuthPasteTokenCommand(
+ {
+ provider: opts.provider as string | undefined,
+ profileId: opts.profileId as string | undefined,
+ expiresIn: opts.expiresIn as string | undefined,
+ },
+ defaultRuntime,
+ );
+ } catch (err) {
+ defaultRuntime.error(String(err));
+ defaultRuntime.exit(1);
+ }
+ });
}
diff --git a/src/cli/parse-duration.test.ts b/src/cli/parse-duration.test.ts
index b72a00626..c26bcc114 100644
--- a/src/cli/parse-duration.test.ts
+++ b/src/cli/parse-duration.test.ts
@@ -19,6 +19,10 @@ describe("parseDurationMs", () => {
expect(parseDurationMs("2h")).toBe(7_200_000);
});
+ it("parses days suffix", () => {
+ expect(parseDurationMs("2d")).toBe(172_800_000);
+ });
+
it("supports decimals", () => {
expect(parseDurationMs("0.5s")).toBe(500);
});
diff --git a/src/cli/parse-duration.ts b/src/cli/parse-duration.ts
index efaea9368..81674fe73 100644
--- a/src/cli/parse-duration.ts
+++ b/src/cli/parse-duration.ts
@@ -1,5 +1,5 @@
export type DurationMsParseOptions = {
- defaultUnit?: "ms" | "s" | "m" | "h";
+ defaultUnit?: "ms" | "s" | "m" | "h" | "d";
};
export function parseDurationMs(
@@ -11,7 +11,7 @@ export function parseDurationMs(
.toLowerCase();
if (!trimmed) throw new Error("invalid duration (empty)");
- const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(trimmed);
if (!m) throw new Error(`invalid duration: ${raw}`);
const value = Number(m[1]);
@@ -19,9 +19,22 @@ export function parseDurationMs(
throw new Error(`invalid duration: ${raw}`);
}
- const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m" | "h";
+ const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as
+ | "ms"
+ | "s"
+ | "m"
+ | "h"
+ | "d";
const multiplier =
- unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
+ unit === "ms"
+ ? 1
+ : unit === "s"
+ ? 1000
+ : unit === "m"
+ ? 60_000
+ : unit === "h"
+ ? 3_600_000
+ : 86_400_000;
const ms = Math.round(value * multiplier);
if (!Number.isFinite(ms)) throw new Error(`invalid duration: ${raw}`);
return ms;
diff --git a/src/commands/models.ts b/src/commands/models.ts
index 4622a4479..636a738cb 100644
--- a/src/commands/models.ts
+++ b/src/commands/models.ts
@@ -3,6 +3,11 @@ export {
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
} from "./models/aliases.js";
+export {
+ modelsAuthAddCommand,
+ modelsAuthPasteTokenCommand,
+ modelsAuthSetupTokenCommand,
+} from "./models/auth.js";
export {
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts
new file mode 100644
index 000000000..e48abeb22
--- /dev/null
+++ b/src/commands/models/auth.ts
@@ -0,0 +1,207 @@
+import { spawnSync } from "node:child_process";
+
+import { confirm, select, text } from "@clack/prompts";
+
+import {
+ CLAUDE_CLI_PROFILE_ID,
+ ensureAuthProfileStore,
+ upsertAuthProfile,
+} from "../../agents/auth-profiles.js";
+import { normalizeProviderId } from "../../agents/model-selection.js";
+import { parseDurationMs } from "../../cli/parse-duration.js";
+import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
+import type { RuntimeEnv } from "../../runtime.js";
+import { applyAuthProfileConfig } from "../onboard-auth.js";
+import { updateConfig } from "./shared.js";
+
+type TokenProvider = "anthropic";
+
+function resolveTokenProvider(raw?: string): TokenProvider | "custom" | null {
+ const trimmed = raw?.trim();
+ if (!trimmed) return null;
+ const normalized = normalizeProviderId(trimmed);
+ if (normalized === "anthropic") return "anthropic";
+ return "custom";
+}
+
+function resolveDefaultTokenProfileId(provider: string): string {
+ return `${normalizeProviderId(provider)}:manual`;
+}
+
+export async function modelsAuthSetupTokenCommand(
+ opts: { provider?: string; yes?: boolean },
+ runtime: RuntimeEnv,
+) {
+ const provider = resolveTokenProvider(opts.provider ?? "anthropic");
+ if (provider !== "anthropic") {
+ throw new Error(
+ "Only --provider anthropic is supported for setup-token (uses `claude setup-token`).",
+ );
+ }
+
+ if (!process.stdin.isTTY) {
+ throw new Error("setup-token requires an interactive TTY.");
+ }
+
+ if (!opts.yes) {
+ const proceed = await confirm({
+ message: "Run `claude setup-token` now?",
+ initialValue: true,
+ });
+ if (!proceed) return;
+ }
+
+ const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
+ if (res.error) throw res.error;
+ if (typeof res.status === "number" && res.status !== 0) {
+ throw new Error(`claude setup-token failed (exit ${res.status})`);
+ }
+
+ const store = ensureAuthProfileStore(undefined, {
+ allowKeychainPrompt: true,
+ });
+ const synced = store.profiles[CLAUDE_CLI_PROFILE_ID];
+ if (!synced) {
+ throw new Error(
+ `No Claude CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
+ );
+ }
+
+ await updateConfig((cfg) =>
+ applyAuthProfileConfig(cfg, {
+ profileId: CLAUDE_CLI_PROFILE_ID,
+ provider: "anthropic",
+ mode: "token",
+ }),
+ );
+
+ runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
+ runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`);
+}
+
+export async function modelsAuthPasteTokenCommand(
+ opts: {
+ provider?: string;
+ profileId?: string;
+ expiresIn?: string;
+ },
+ runtime: RuntimeEnv,
+) {
+ const rawProvider = opts.provider?.trim();
+ if (!rawProvider) {
+ throw new Error("Missing --provider.");
+ }
+ const provider = normalizeProviderId(rawProvider);
+ const profileId =
+ opts.profileId?.trim() || resolveDefaultTokenProfileId(provider);
+
+ const tokenInput = await text({
+ message: `Paste token for ${provider}`,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const token = String(tokenInput).trim();
+
+ const expires =
+ opts.expiresIn?.trim() && opts.expiresIn.trim().length > 0
+ ? Date.now() +
+ parseDurationMs(String(opts.expiresIn).trim(), { defaultUnit: "d" })
+ : undefined;
+
+ upsertAuthProfile({
+ profileId,
+ credential: {
+ type: "token",
+ provider,
+ token,
+ ...(expires ? { expires } : {}),
+ },
+ });
+
+ await updateConfig((cfg) =>
+ applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }),
+ );
+
+ runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
+ runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
+}
+
+export async function modelsAuthAddCommand(
+ _opts: Record,
+ runtime: RuntimeEnv,
+) {
+ const provider = (await select({
+ message: "Token provider",
+ options: [
+ { value: "anthropic", label: "anthropic" },
+ { value: "custom", label: "custom (type provider id)" },
+ ],
+ })) as TokenProvider | "custom";
+
+ const providerId =
+ provider === "custom"
+ ? normalizeProviderId(
+ String(
+ await text({
+ message: "Provider id",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ),
+ )
+ : provider;
+
+ const method = (await select({
+ message: "Token method",
+ options: [
+ ...(providerId === "anthropic"
+ ? [
+ {
+ value: "setup-token",
+ label: "setup-token (claude)",
+ hint: "Runs `claude setup-token` (recommended)",
+ },
+ ]
+ : []),
+ { value: "paste", label: "paste token" },
+ ],
+ })) as "setup-token" | "paste";
+
+ if (method === "setup-token") {
+ await modelsAuthSetupTokenCommand({ provider: providerId }, runtime);
+ return;
+ }
+
+ const profileIdDefault = resolveDefaultTokenProfileId(providerId);
+ const profileId = String(
+ await text({
+ message: "Profile id",
+ initialValue: profileIdDefault,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+
+ const wantsExpiry = await confirm({
+ message: "Does this token expire?",
+ initialValue: false,
+ });
+ const expiresIn = wantsExpiry
+ ? String(
+ await text({
+ message: "Expires in (duration)",
+ initialValue: "365d",
+ validate: (value) => {
+ try {
+ parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
+ return undefined;
+ } catch {
+ return "Invalid duration (e.g. 365d, 12h, 30m)";
+ }
+ },
+ }),
+ ).trim()
+ : undefined;
+
+ await modelsAuthPasteTokenCommand(
+ { provider: providerId, profileId, expiresIn },
+ runtime,
+ );
+}
From 37cbcc97d324f0a1b91a4487a392d4051b0e7ed4 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:51:47 +0100
Subject: [PATCH 021/220] feat: support token auth profiles
---
src/agents/auth-health.ts | 43 ++++-
src/agents/auth-profiles.test.ts | 11 +-
src/agents/auth-profiles.ts | 130 +++++++++++---
src/agents/model-auth.ts | 10 +-
src/auto-reply/reply/commands.ts | 4 +
src/auto-reply/reply/directive-handling.ts | 7 +-
src/commands/auth-choice-options.ts | 13 +-
src/commands/auth-choice.ts | 200 ++++++++++++++++-----
src/commands/doctor-auth.ts | 10 +-
src/commands/models/list.ts | 35 +++-
src/commands/onboard-auth.test.ts | 4 +-
src/commands/onboard-auth.ts | 2 +-
src/commands/onboard-non-interactive.ts | 17 +-
src/commands/onboard-types.ts | 1 +
src/config/types.ts | 8 +-
src/config/zod-schema.ts | 6 +-
16 files changed, 388 insertions(+), 113 deletions(-)
diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts
index 51e969b94..8455b4727 100644
--- a/src/agents/auth-health.ts
+++ b/src/agents/auth-health.ts
@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
export type AuthProfileHealth = {
profileId: string;
provider: string;
- type: "oauth" | "api_key";
+ type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
expiresAt?: number;
remainingMs?: number;
@@ -109,6 +109,39 @@ function buildProfileHealth(params: {
};
}
+ if (credential.type === "token") {
+ const expiresAt =
+ typeof credential.expires === "number" &&
+ Number.isFinite(credential.expires)
+ ? credential.expires
+ : undefined;
+ if (!expiresAt || expiresAt <= 0) {
+ return {
+ profileId,
+ provider: credential.provider,
+ type: "token",
+ status: "static",
+ source,
+ label,
+ };
+ }
+ const { status, remainingMs } = resolveOAuthStatus(
+ expiresAt,
+ now,
+ warnAfterMs,
+ );
+ return {
+ profileId,
+ provider: credential.provider,
+ type: "token",
+ status,
+ expiresAt,
+ remainingMs,
+ source,
+ label,
+ };
+ }
+
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
}
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
+ const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
- if (oauthProfiles.length === 0) {
+ const expirable = [...oauthProfiles, ...tokenProfiles];
+ if (expirable.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue;
}
- const expiryCandidates = oauthProfiles
+ const expiryCandidates = expirable
.map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) {
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
provider.remainingMs = provider.expiresAt - now;
}
- const statuses = oauthProfiles.map((p) => p.status);
+ const statuses = expirable.map((p) => p.status);
if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired";
} else if (statuses.includes("expiring")) {
diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts
index 1e75668e1..0c582e7bc 100644
--- a/src/agents/auth-profiles.test.ts
+++ b/src/agents/auth-profiles.test.ts
@@ -428,7 +428,7 @@ describe("external CLI credential sync", () => {
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
- (store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
+ (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("fresh-access-token");
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
@@ -537,7 +537,7 @@ describe("external CLI credential sync", () => {
}
});
- it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
+ it("does not overwrite fresher store token with older Claude CLI credentials", () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
);
@@ -567,10 +567,9 @@ describe("external CLI credential sync", () => {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
- type: "oauth",
+ type: "token",
provider: "anthropic",
- access: "store-access",
- refresh: "store-refresh",
+ token: "store-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
@@ -579,7 +578,7 @@ describe("external CLI credential sync", () => {
const store = ensureAuthProfileStore(agentDir);
expect(
- (store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
+ (store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access");
} finally {
restoreHomeEnv(originalHome);
diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts
index c0348c0e7..780d476d8 100644
--- a/src/agents/auth-profiles.ts
+++ b/src/agents/auth-profiles.ts
@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
email?: string;
};
+export type TokenCredential = {
+ /**
+ * Static bearer-style token (often OAuth access token / PAT).
+ * Not refreshable by clawdbot (unlike `type: "oauth"`).
+ */
+ type: "token";
+ provider: string;
+ token: string;
+ /** Optional expiry timestamp (ms since epoch). */
+ expires?: number;
+ email?: string;
+};
+
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: OAuthProvider;
email?: string;
};
-export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
+export type AuthProfileCredential =
+ | ApiKeyCredential
+ | TokenCredential
+ | OAuthCredential;
/** Per-profile usage statistics for round-robin and cooldown tracking */
export type ProfileUsageStats = {
@@ -220,7 +236,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial;
- if (typed.type !== "api_key" && typed.type !== "oauth") continue;
+ if (
+ typed.type !== "api_key" &&
+ typed.type !== "oauth" &&
+ typed.type !== "token"
+ ) {
+ continue;
+ }
entries[key] = {
...typed,
provider: typed.provider ?? (key as OAuthProvider),
@@ -238,7 +260,13 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial;
- if (typed.type !== "api_key" && typed.type !== "oauth") continue;
+ if (
+ typed.type !== "api_key" &&
+ typed.type !== "oauth" &&
+ typed.type !== "token"
+ ) {
+ continue;
+ }
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
@@ -285,7 +313,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
*/
function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
-}): OAuthCredential | null {
+}): TokenCredential | null {
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) {
@@ -306,18 +334,15 @@ function readClaudeCliCredentials(options?: {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
- const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
- if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
- type: "oauth",
+ type: "token",
provider: "anthropic",
- access: accessToken,
- refresh: refreshToken,
+ token: accessToken,
expires: expiresAt,
};
}
@@ -326,7 +351,7 @@ function readClaudeCliCredentials(options?: {
* Read Claude Code credentials from macOS keychain.
* Uses the `security` CLI to access keychain without native dependencies.
*/
-function readClaudeCliKeychainCredentials(): OAuthCredential | null {
+function readClaudeCliKeychainCredentials(): TokenCredential | null {
try {
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w',
@@ -338,18 +363,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
- const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
- if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
- type: "oauth",
+ type: "token",
provider: "anthropic",
- access: accessToken,
- refresh: refreshToken,
+ token: accessToken,
expires: expiresAt,
};
} catch {
@@ -416,6 +438,20 @@ function shallowEqualOAuthCredentials(
);
}
+function shallowEqualTokenCredentials(
+ a: TokenCredential | undefined,
+ b: TokenCredential,
+): boolean {
+ if (!a) return false;
+ if (a.type !== "token") return false;
+ return (
+ a.provider === b.provider &&
+ a.token === b.token &&
+ a.expires === b.expires &&
+ a.email === b.email
+ );
+}
+
/**
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
@@ -434,25 +470,28 @@ function syncExternalCliCredentials(
const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
- const existingOAuth = existing?.type === "oauth" ? existing : undefined;
+ const existingToken = existing?.type === "token" ? existing : undefined;
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
const shouldUpdate =
- !existingOAuth ||
- existingOAuth.provider !== "anthropic" ||
- existingOAuth.expires <= now ||
- (claudeCreds.expires > now &&
- claudeCreds.expires > existingOAuth.expires);
+ !existingToken ||
+ existingToken.provider !== "anthropic" ||
+ (existingToken.expires ?? 0) <= now ||
+ ((claudeCreds.expires ?? 0) > now &&
+ (claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
if (
shouldUpdate &&
- !shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
+ !shallowEqualTokenCredentials(existingToken, claudeCreds)
) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
- expires: new Date(claudeCreds.expires).toISOString(),
+ expires:
+ typeof claudeCreds.expires === "number"
+ ? new Date(claudeCreds.expires).toISOString()
+ : "unknown",
});
}
}
@@ -515,6 +554,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
+ } else if (cred.type === "token") {
+ store.profiles[profileId] = {
+ type: "token",
+ provider: cred.provider ?? (provider as OAuthProvider),
+ token: cred.token,
+ ...(typeof cred.expires === "number"
+ ? { expires: cred.expires }
+ : {}),
+ ...(cred.email ? { email: cred.email } : {}),
+ };
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -570,6 +619,16 @@ export function ensureAuthProfileStore(
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
+ } else if (cred.type === "token") {
+ store.profiles[profileId] = {
+ type: "token",
+ provider: cred.provider ?? (provider as OAuthProvider),
+ token: cred.token,
+ ...(typeof cred.expires === "number"
+ ? { expires: cred.expires }
+ : {}),
+ ...(cred.email ? { email: cred.email } : {}),
+ };
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -882,16 +941,17 @@ function orderProfilesByMode(
// Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
- const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
+ const typeScore =
+ type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed };
});
- // Primary sort: type preference (oauth > api_key).
+ // Primary sort: type preference (oauth > token > api_key).
// Secondary sort: lastUsed (oldest first for round-robin within type).
const sorted = scored
.sort((a, b) => {
- // First by type (oauth > api_key)
+ // First by type (oauth > token > api_key)
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
// Then by lastUsed (oldest first)
return a.lastUsed - b.lastUsed;
@@ -921,11 +981,27 @@ export async function resolveApiKeyForProfile(params: {
if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
- if (profileConfig && profileConfig.mode !== cred.type) return null;
+ if (profileConfig && profileConfig.mode !== cred.type) {
+ // Compatibility: treat "oauth" config as compatible with stored token profiles.
+ if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
+ }
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
+ if (cred.type === "token") {
+ const token = cred.token?.trim();
+ if (!token) return null;
+ if (
+ typeof cred.expires === "number" &&
+ Number.isFinite(cred.expires) &&
+ cred.expires > 0 &&
+ Date.now() >= cred.expires
+ ) {
+ return null;
+ }
+ return { apiKey: token, provider: cred.provider, email: cred.email };
+ }
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index 4390747ac..22ff3879b 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
}
export type EnvApiKeyResult = { apiKey: string; source: string };
-export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
+export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys());
@@ -158,10 +158,14 @@ export function resolveModelAuthMode(
const modes = new Set(
profiles
.map((id) => authStore.profiles[id]?.type)
- .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
+ .filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
);
- if (modes.has("oauth") && modes.has("api_key")) return "mixed";
+ const distinct = ["oauth", "token", "api_key"].filter((k) =>
+ modes.has(k as "oauth" | "token" | "api_key"),
+ );
+ if (distinct.length >= 2) return "mixed";
if (modes.has("oauth")) return "oauth";
+ if (modes.has("token")) return "token";
if (modes.has("api_key")) return "api-key";
}
diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts
index f955525bb..5102d1b78 100644
--- a/src/auto-reply/reply/commands.ts
+++ b/src/auto-reply/reply/commands.ts
@@ -236,6 +236,10 @@ function resolveModelAuthLabel(
if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`;
}
+ if (profile.type === "token") {
+ const snippet = formatApiKeySnippet(profile.token);
+ return `token ${snippet}${label ? ` (${label})` : ""}`;
+ }
const snippet = formatApiKeySnippet(profile.key);
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
}
diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts
index 44e3fe279..15a89b79e 100644
--- a/src/auto-reply/reply/directive-handling.ts
+++ b/src/auto-reply/reply/directive-handling.ts
@@ -88,13 +88,18 @@ const resolveAuthLabel = async (
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
- (configProfile?.mode && configProfile.mode !== profile.type)
+ (configProfile?.mode &&
+ configProfile.mode !== profile.type &&
+ !(configProfile.mode === "oauth" && profile.type === "token"))
) {
return `${profileId}=missing`;
}
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
+ if (profile.type === "token") {
+ return `${profileId}=token:${maskApiKey(profile.token)}`;
+ }
const display = resolveAuthProfileDisplayLabel({
cfg,
store,
diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts
index 6c161ad51..a5bd2aa29 100644
--- a/src/commands/auth-choice-options.ts
+++ b/src/commands/auth-choice-options.ts
@@ -61,7 +61,7 @@ export function buildAuthChoiceOptions(params: {
}
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
- if (claudeCli?.type === "oauth") {
+ if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic OAuth (Claude CLI)",
@@ -75,7 +75,11 @@ export function buildAuthChoiceOptions(params: {
});
}
- options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
+ options.push({
+ value: "oauth",
+ label: "Anthropic token (setup-token)",
+ hint: "Runs `claude setup-token`",
+ });
options.push({
value: "openai-codex",
@@ -87,6 +91,11 @@ export function buildAuthChoiceOptions(params: {
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
+ options.push({
+ value: "token",
+ label: "Paste token (advanced)",
+ hint: "Stores as a non-refreshable token profile",
+ });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });
diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts
index 42eba1ad3..45bf5cf9a 100644
--- a/src/commands/auth-choice.ts
+++ b/src/commands/auth-choice.ts
@@ -1,5 +1,4 @@
import {
- loginAnthropic,
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
@@ -10,6 +9,7 @@ import {
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
+ upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
@@ -17,7 +17,11 @@ import {
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
-import { resolveConfiguredModelRef } from "../agents/model-selection.js";
+import {
+ normalizeProviderId,
+ resolveConfiguredModelRef,
+} from "../agents/model-selection.js";
+import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
@@ -134,44 +138,62 @@ export async function applyAuthChoice(params: {
if (params.authChoice === "oauth") {
await params.prompter.note(
- "Browser will open. Paste the code shown after login (code#state).",
- "Anthropic OAuth",
+ [
+ "This will run `claude setup-token` to create a long-lived Anthropic token.",
+ "Requires an interactive TTY and a Claude Pro/Max subscription.",
+ ].join("\n"),
+ "Anthropic token",
);
- const spin = params.prompter.progress("Waiting for authorization…");
- let oauthCreds: OAuthCredentials | null = null;
- try {
- oauthCreds = await loginAnthropic(
- async (url) => {
- await openUrl(url);
- params.runtime.log(`Open: ${url}`);
- },
- async () => {
- const code = await params.prompter.text({
- message: "Paste authorization code (code#state)",
- validate: (value) => (value?.trim() ? undefined : "Required"),
- });
- return String(code);
- },
- );
- spin.stop("OAuth complete");
- if (oauthCreds) {
- await writeOAuthCredentials("anthropic", oauthCreds, params.agentDir);
- const profileId = `anthropic:${oauthCreds.email ?? "default"}`;
- nextConfig = applyAuthProfileConfig(nextConfig, {
- profileId,
- provider: "anthropic",
- mode: "oauth",
- email: oauthCreds.email ?? undefined,
- });
- }
- } catch (err) {
- spin.stop("OAuth failed");
- params.runtime.error(String(err));
+
+ if (!process.stdin.isTTY) {
await params.prompter.note(
- "Trouble with OAuth? See https://docs.clawd.bot/start/faq",
- "OAuth help",
+ "`claude setup-token` requires an interactive TTY.",
+ "Anthropic token",
);
+ return { config: nextConfig, agentModelOverride };
}
+
+ const proceed = await params.prompter.confirm({
+ message: "Run `claude setup-token` now?",
+ initialValue: true,
+ });
+ if (!proceed) return { config: nextConfig, agentModelOverride };
+
+ const res = await (async () => {
+ const { spawnSync } = await import("node:child_process");
+ return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
+ })();
+ if (res.error) {
+ await params.prompter.note(
+ `Failed to run claude: ${String(res.error)}`,
+ "Anthropic token",
+ );
+ return { config: nextConfig, agentModelOverride };
+ }
+ if (typeof res.status === "number" && res.status !== 0) {
+ await params.prompter.note(
+ `claude setup-token failed (exit ${res.status})`,
+ "Anthropic token",
+ );
+ return { config: nextConfig, agentModelOverride };
+ }
+
+ const store = ensureAuthProfileStore(params.agentDir, {
+ allowKeychainPrompt: true,
+ });
+ if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
+ await params.prompter.note(
+ `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
+ "Anthropic token",
+ );
+ return { config: nextConfig, agentModelOverride };
+ }
+
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId: CLAUDE_CLI_PROFILE_ID,
+ provider: "anthropic",
+ mode: "token",
+ });
} else if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
@@ -202,18 +224,108 @@ export async function applyAuthChoice(params: {
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
- await params.prompter.note(
- process.platform === "darwin"
- ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
- : "No Claude CLI credentials found at ~/.claude/.credentials.json.",
- "Claude CLI OAuth",
- );
- return { config: nextConfig, agentModelOverride };
+ if (process.stdin.isTTY) {
+ const runNow = await params.prompter.confirm({
+ message: "Run `claude setup-token` now?",
+ initialValue: true,
+ });
+ if (runNow) {
+ const res = await (async () => {
+ const { spawnSync } = await import("node:child_process");
+ return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
+ })();
+ if (res.error) {
+ await params.prompter.note(
+ `Failed to run claude: ${String(res.error)}`,
+ "Claude setup-token",
+ );
+ }
+ }
+ } else {
+ await params.prompter.note(
+ "`claude setup-token` requires an interactive TTY.",
+ "Claude setup-token",
+ );
+ }
+
+ const refreshed = ensureAuthProfileStore(params.agentDir, {
+ allowKeychainPrompt: true,
+ });
+ if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
+ await params.prompter.note(
+ process.platform === "darwin"
+ ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
+ : "No Claude CLI credentials found at ~/.claude/.credentials.json.",
+ "Claude CLI OAuth",
+ );
+ return { config: nextConfig, agentModelOverride };
+ }
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
- mode: "oauth",
+ mode: "token",
+ });
+ } else if (params.authChoice === "token") {
+ const providerRaw = await params.prompter.text({
+ message: "Token provider id (e.g. anthropic)",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const provider = normalizeProviderId(String(providerRaw).trim());
+ const defaultProfileId = `${provider}:manual`;
+
+ const profileIdRaw = await params.prompter.text({
+ message: "Auth profile id",
+ initialValue: defaultProfileId,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const profileId = String(profileIdRaw).trim();
+
+ const tokenRaw = await params.prompter.text({
+ message: `Paste token for ${provider}`,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const token = String(tokenRaw).trim();
+
+ const wantsExpiry = await params.prompter.confirm({
+ message: "Does this token expire?",
+ initialValue: false,
+ });
+ const expiresInRaw = wantsExpiry
+ ? await params.prompter.text({
+ message: "Expires in (duration)",
+ initialValue: "365d",
+ validate: (value) => {
+ try {
+ parseDurationMs(String(value ?? ""), { defaultUnit: "d" });
+ return undefined;
+ } catch {
+ return "Invalid duration (e.g. 365d, 12h, 30m)";
+ }
+ },
+ })
+ : "";
+
+ const expiresIn = String(expiresInRaw).trim();
+ const expires = expiresIn
+ ? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" })
+ : undefined;
+
+ upsertAuthProfile({
+ profileId,
+ agentDir: params.agentDir,
+ credential: {
+ type: "token",
+ provider,
+ token,
+ ...(expires ? { expires } : {}),
+ },
+ });
+
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId,
+ provider,
+ mode: "token",
});
} else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment();
diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts
index d02cb4974..13a158846 100644
--- a/src/commands/doctor-auth.ts
+++ b/src/commands/doctor-auth.ts
@@ -86,7 +86,7 @@ export async function noteAuthProfileHealth(params: {
const findIssues = () =>
summary.profiles.filter(
(profile) =>
- profile.type === "oauth" &&
+ (profile.type === "oauth" || profile.type === "token") &&
(profile.status === "expired" ||
profile.status === "expiring" ||
profile.status === "missing"),
@@ -96,13 +96,15 @@ export async function noteAuthProfileHealth(params: {
if (issues.length === 0) return;
const shouldRefresh = await params.prompter.confirmRepair({
- message: "Refresh expiring OAuth tokens now?",
+ message: "Refresh expiring OAuth tokens now? (static tokens need re-auth)",
initialValue: true,
});
if (shouldRefresh) {
- const refreshTargets = issues.filter((issue) =>
- ["expired", "expiring", "missing"].includes(issue.status),
+ const refreshTargets = issues.filter(
+ (issue) =>
+ issue.type === "oauth" &&
+ ["expired", "expiring", "missing"].includes(issue.status),
);
const errors: string[] = [];
for (const profile of refreshTargets) {
diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts
index 5a3e178a3..0ec63a3ab 100644
--- a/src/commands/models/list.ts
+++ b/src/commands/models/list.ts
@@ -159,6 +159,7 @@ type ProviderAuthOverview = {
profiles: {
count: number;
oauth: number;
+ token: number;
apiKey: number;
labels: string[];
};
@@ -180,6 +181,9 @@ function resolveProviderAuthOverview(params: {
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
}
+ if (profile.type === "token") {
+ return `${profileId}=token:${maskApiKey(profile.token)}`;
+ }
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const suffix =
display === profileId
@@ -192,6 +196,9 @@ function resolveProviderAuthOverview(params: {
const oauthCount = profiles.filter(
(id) => store.profiles[id]?.type === "oauth",
).length;
+ const tokenCount = profiles.filter(
+ (id) => store.profiles[id]?.type === "token",
+ ).length;
const apiKeyCount = profiles.filter(
(id) => store.profiles[id]?.type === "api_key",
).length;
@@ -227,6 +234,7 @@ function resolveProviderAuthOverview(params: {
profiles: {
count: profiles.length,
oauth: oauthCount,
+ token: tokenCount,
apiKey: apiKeyCount,
labels,
},
@@ -739,11 +747,16 @@ export async function modelsStatusCommand(
const providersWithOauth = providerAuth
.filter(
- (entry) => entry.profiles.oauth > 0 || entry.env?.value === "OAuth (env)",
+ (entry) =>
+ entry.profiles.oauth > 0 ||
+ entry.profiles.token > 0 ||
+ entry.env?.value === "OAuth (env)",
)
.map((entry) => {
const count =
- entry.profiles.oauth || (entry.env?.value === "OAuth (env)" ? 1 : 0);
+ entry.profiles.oauth +
+ entry.profiles.token +
+ (entry.env?.value === "OAuth (env)" ? 1 : 0);
return `${entry.provider} (${count})`;
});
@@ -754,7 +767,7 @@ export async function modelsStatusCommand(
providers,
});
const oauthProfiles = authHealth.profiles.filter(
- (profile) => profile.type === "oauth",
+ (profile) => profile.type === "oauth" || profile.type === "token",
);
const checkStatus = (() => {
@@ -926,7 +939,7 @@ export async function modelsStatusCommand(
);
runtime.log(
`${label(
- `Providers w/ OAuth (${providersWithOauth.length || 0})`,
+ `Providers w/ OAuth/tokens (${providersWithOauth.length || 0})`,
)}${colorize(rich, theme.muted, ":")} ${colorize(
rich,
providersWithOauth.length ? theme.info : theme.muted,
@@ -953,7 +966,7 @@ export async function modelsStatusCommand(
bits.push(
formatKeyValue(
"profiles",
- `${entry.profiles.count} (oauth=${entry.profiles.oauth}, api_key=${entry.profiles.apiKey})`,
+ `${entry.profiles.count} (oauth=${entry.profiles.oauth}, token=${entry.profiles.token}, api_key=${entry.profiles.apiKey})`,
rich,
),
);
@@ -1003,7 +1016,7 @@ export async function modelsStatusCommand(
}
runtime.log("");
- runtime.log(colorize(rich, theme.heading, "OAuth status"));
+ runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
return;
@@ -1011,6 +1024,7 @@ export async function modelsStatusCommand(
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
+ if (status === "static") return colorize(rich, theme.muted, "static");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
@@ -1020,9 +1034,12 @@ export async function modelsStatusCommand(
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
- const expiry = profile.expiresAt
- ? ` expires in ${formatRemainingShort(profile.remainingMs)}`
- : " expires unknown";
+ const expiry =
+ profile.status === "static"
+ ? ""
+ : profile.expiresAt
+ ? ` expires in ${formatRemainingShort(profile.remainingMs)}`
+ : " expires unknown";
const source =
profile.source !== "store"
? colorize(rich, theme.muted, ` (${profile.source})`)
diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts
index 5def9a6ef..a970ca032 100644
--- a/src/commands/onboard-auth.test.ts
+++ b/src/commands/onboard-auth.test.ts
@@ -52,7 +52,7 @@ describe("writeOAuthCredentials", () => {
expires: Date.now() + 60_000,
} satisfies OAuthCredentials;
- await writeOAuthCredentials("anthropic", creds);
+ await writeOAuthCredentials("openai-codex", creds);
// Now writes to the multi-agent path: agents/main/agent
const authProfilePath = path.join(
@@ -66,7 +66,7 @@ describe("writeOAuthCredentials", () => {
const parsed = JSON.parse(raw) as {
profiles?: Record;
};
- expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
+ expect(parsed.profiles?.["openai-codex:default"]).toMatchObject({
refresh: "refresh-token",
access: "access-token",
type: "oauth",
diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts
index e1116a98d..76e976c22 100644
--- a/src/commands/onboard-auth.ts
+++ b/src/commands/onboard-auth.ts
@@ -51,7 +51,7 @@ export function applyAuthProfileConfig(
params: {
profileId: string;
provider: string;
- mode: "api_key" | "oauth";
+ mode: "api_key" | "oauth" | "token";
email?: string;
preferProfileFirst?: boolean;
},
diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts
index 51bb6ee84..73c8fc888 100644
--- a/src/commands/onboard-non-interactive.ts
+++ b/src/commands/onboard-non-interactive.ts
@@ -151,7 +151,7 @@ export async function runNonInteractiveOnboarding(
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
- mode: "oauth",
+ mode: "token",
});
} else if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore();
@@ -169,17 +169,18 @@ export async function runNonInteractiveOnboarding(
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
} else if (
+ authChoice === "token" ||
authChoice === "oauth" ||
authChoice === "openai-codex" ||
authChoice === "antigravity"
) {
- runtime.error(
- `${
- authChoice === "oauth" || authChoice === "openai-codex"
- ? "OAuth"
- : "Antigravity"
- } requires interactive mode.`,
- );
+ const label =
+ authChoice === "antigravity"
+ ? "Antigravity"
+ : authChoice === "token"
+ ? "Token"
+ : "OAuth";
+ runtime.error(`${label} requires interactive mode.`);
runtime.exit(1);
return;
}
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index 6ee3b5fc9..159cd11e6 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -5,6 +5,7 @@ export type OnboardMode = "local" | "remote";
export type AuthChoice =
| "oauth"
| "claude-cli"
+ | "token"
| "openai-codex"
| "codex-cli"
| "antigravity"
diff --git a/src/config/types.ts b/src/config/types.ts
index a4c7bae17..b99312195 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -985,7 +985,13 @@ export type ModelsConfig = {
export type AuthProfileConfig = {
provider: string;
- mode: "api_key" | "oauth";
+ /**
+ * Credential type expected in auth-profiles.json for this profile id.
+ * - api_key: static provider API key
+ * - oauth: refreshable OAuth credentials (access+refresh+expires)
+ * - token: static bearer-style token (optionally expiring; no refresh)
+ */
+ mode: "api_key" | "oauth" | "token";
email?: string;
};
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 9045d7b3c..083d53a11 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -895,7 +895,11 @@ export const ClawdbotSchema = z.object({
z.string(),
z.object({
provider: z.string(),
- mode: z.union([z.literal("api_key"), z.literal("oauth")]),
+ mode: z.union([
+ z.literal("api_key"),
+ z.literal("oauth"),
+ z.literal("token"),
+ ]),
email: z.string().optional(),
}),
)
From 185727c696d73550e32df3df92f9f7afc7c9693a Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:52:28 +0100
Subject: [PATCH 022/220] style: fix lint formatting
---
src/agents/pi-tools.test.ts | 9 ++-
src/agents/tools/message-tool.ts | 12 ++-
src/cli/gateway-cli.coverage.test.ts | 4 +-
src/cli/program.test.ts | 9 ++-
src/cli/program.ts | 2 +-
src/commands/message.ts | 15 ++--
src/infra/bonjour-discovery.test.ts | 114 ++++++++++++++-------------
7 files changed, 92 insertions(+), 73 deletions(-)
diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts
index 94e8fcd2b..de754771c 100644
--- a/src/agents/pi-tools.test.ts
+++ b/src/agents/pi-tools.test.ts
@@ -66,7 +66,14 @@ describe("createClawdbotCodingTools", () => {
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
- const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
+ const toolNames = [
+ "browser",
+ "canvas",
+ "nodes",
+ "cron",
+ "gateway",
+ "message",
+ ];
const collectActionValues = (
schema: unknown,
diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts
index 9f8ba566a..29261cb1b 100644
--- a/src/agents/tools/message-tool.ts
+++ b/src/agents/tools/message-tool.ts
@@ -1,10 +1,10 @@
import { Type } from "@sinclair/typebox";
import {
- sendMessage,
- sendPoll,
type MessagePollResult,
type MessageSendResult,
+ sendMessage,
+ sendPoll,
} from "../../infra/outbound/message.js";
import type { AnyAgentTool } from "./common.js";
import {
@@ -64,7 +64,9 @@ export function createMessageTool(): AnyAgentTool {
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
- typeof params.bestEffort === "boolean" ? params.bestEffort : undefined;
+ typeof params.bestEffort === "boolean"
+ ? params.bestEffort
+ : undefined;
const result: MessageSendResult = await sendMessage({
to,
@@ -82,7 +84,9 @@ export function createMessageTool(): AnyAgentTool {
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
- const question = readStringParam(params, "question", { required: true });
+ const question = readStringParam(params, "question", {
+ required: true,
+ });
const options =
readStringArrayParam(params, "options", { required: true }) ?? [];
const maxSelections = readNumberParam(params, "maxSelections", {
diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts
index 98d52db62..1cf7f01a6 100644
--- a/src/cli/gateway-cli.coverage.test.ts
+++ b/src/cli/gateway-cli.coverage.test.ts
@@ -138,7 +138,9 @@ describe("gateway-cli coverage", () => {
program.exitOverride();
registerGatewayCli(program);
- await program.parseAsync(["gateway", "discover", "--json"], { from: "user" });
+ await program.parseAsync(["gateway", "discover", "--json"], {
+ from: "user",
+ });
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"beacons"');
diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts
index 7f8d676cb..6a215f582 100644
--- a/src/cli/program.test.ts
+++ b/src/cli/program.test.ts
@@ -48,9 +48,12 @@ describe("cli program", () => {
it("runs message send with required options", async () => {
const program = buildProgram();
- await program.parseAsync(["message", "send", "--to", "+1", "--message", "hi"], {
- from: "user",
- });
+ await program.parseAsync(
+ ["message", "send", "--to", "+1", "--message", "hi"],
+ {
+ from: "user",
+ },
+ );
expect(messageSendCommand).toHaveBeenCalled();
});
diff --git a/src/cli/program.ts b/src/cli/program.ts
index 1b8f20675..d17f34bc2 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -8,8 +8,8 @@ import {
import { configureCommand } from "../commands/configure.js";
import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
-import { onboardCommand } from "../commands/onboard.js";
import { messagePollCommand, messageSendCommand } from "../commands/message.js";
+import { onboardCommand } from "../commands/onboard.js";
import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js";
import { statusCommand } from "../commands/status.js";
diff --git a/src/commands/message.ts b/src/commands/message.ts
index 63ce103fe..fced32348 100644
--- a/src/commands/message.ts
+++ b/src/commands/message.ts
@@ -9,10 +9,10 @@ import {
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import {
- sendMessage,
- sendPoll,
type MessagePollResult,
type MessageSendResult,
+ sendMessage,
+ sendPoll,
} from "../infra/outbound/message.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
@@ -49,7 +49,11 @@ function parseIntOption(value: unknown, label: string): number | undefined {
return parsed;
}
-function logSendDryRun(opts: MessageSendOpts, provider: string, runtime: RuntimeEnv) {
+function logSendDryRun(
+ opts: MessageSendOpts,
+ provider: string,
+ runtime: RuntimeEnv,
+) {
runtime.log(
`[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${
opts.media ? ` (media ${opts.media})` : ""
@@ -57,10 +61,7 @@ function logSendDryRun(opts: MessageSendOpts, provider: string, runtime: Runtime
);
}
-function logPollDryRun(
- result: MessagePollResult,
- runtime: RuntimeEnv,
-) {
+function logPollDryRun(result: MessagePollResult, runtime: RuntimeEnv) {
runtime.log(
`[dry-run] would send poll via ${result.provider} -> ${result.to}:\n Question: ${result.question}\n Options: ${result.options.join(
", ",
diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts
index 353a0dab4..d30e726b1 100644
--- a/src/infra/bonjour-discovery.test.ts
+++ b/src/infra/bonjour-discovery.test.ts
@@ -1,23 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import type { runCommandWithTimeout } from "../process/exec.js";
-import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
import { discoverGatewayBeacons } from "./bonjour-discovery.js";
+import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
describe("bonjour-discovery", () => {
it("discovers beacons on darwin across local + wide-area domains", async () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
- const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
- calls.push({ argv, timeoutMs: options.timeoutMs });
- const domain = argv[3] ?? "";
+ const run = vi.fn(
+ async (argv: string[], options: { timeoutMs: number }) => {
+ calls.push({ argv, timeoutMs: options.timeoutMs });
+ const domain = argv[3] ?? "";
+
+ if (argv[0] === "dns-sd" && argv[1] === "-B") {
+ if (domain === "local.") {
+ return {
+ stdout: [
+ "Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
+ "Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
+ "",
+ ].join("\n"),
+ stderr: "",
+ code: 0,
+ signal: null,
+ killed: false,
+ };
+ }
+ if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
+ return {
+ stdout: [
+ `Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
+ "",
+ ].join("\n"),
+ stderr: "",
+ code: 0,
+ signal: null,
+ killed: false,
+ };
+ }
+ }
+
+ if (argv[0] === "dns-sd" && argv[1] === "-L") {
+ const instance = argv[2] ?? "";
+ const host =
+ instance === "Studio Bridge"
+ ? "studio.local"
+ : instance === "Laptop Bridge"
+ ? "laptop.local"
+ : "tailnet.local";
+ const tailnetDns =
+ instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
+ const txtParts = [
+ "txtvers=1",
+ `displayName=${instance.replace(" Bridge", "")}`,
+ `lanHost=${host}`,
+ "gatewayPort=18789",
+ "bridgePort=18790",
+ "sshPort=22",
+ tailnetDns ? `tailnetDns=${tailnetDns}` : null,
+ ].filter((v): v is string => Boolean(v));
- if (argv[0] === "dns-sd" && argv[1] === "-B") {
- if (domain === "local.") {
return {
stdout: [
- "Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge",
- "Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
+ `${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
+ txtParts.join(" "),
"",
].join("\n"),
stderr: "",
@@ -26,55 +73,10 @@ describe("bonjour-discovery", () => {
killed: false,
};
}
- if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
- return {
- stdout: [
- `Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
- "",
- ].join("\n"),
- stderr: "",
- code: 0,
- signal: null,
- killed: false,
- };
- }
- }
- if (argv[0] === "dns-sd" && argv[1] === "-L") {
- const instance = argv[2] ?? "";
- const host =
- instance === "Studio Bridge"
- ? "studio.local"
- : instance === "Laptop Bridge"
- ? "laptop.local"
- : "tailnet.local";
- const tailnetDns =
- instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
- const txtParts = [
- "txtvers=1",
- `displayName=${instance.replace(" Bridge", "")}`,
- `lanHost=${host}`,
- "gatewayPort=18789",
- "bridgePort=18790",
- "sshPort=22",
- tailnetDns ? `tailnetDns=${tailnetDns}` : null,
- ].filter((v): v is string => Boolean(v));
-
- return {
- stdout: [
- `${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
- txtParts.join(" "),
- "",
- ].join("\n"),
- stderr: "",
- code: 0,
- signal: null,
- killed: false,
- };
- }
-
- throw new Error(`unexpected argv: ${argv.join(" ")}`);
- });
+ throw new Error(`unexpected argv: ${argv.join(" ")}`);
+ },
+ );
const beacons = await discoverGatewayBeacons({
platform: "darwin",
From c32e3c467d180ec1b42bdfeb0e23880a035fc3bd Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:54:33 +0100
Subject: [PATCH 023/220] fix: map CLI deps for outbound sends
---
src/commands/message.ts | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/src/commands/message.ts b/src/commands/message.ts
index fced32348..97e83319f 100644
--- a/src/commands/message.ts
+++ b/src/commands/message.ts
@@ -9,6 +9,7 @@ import {
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
import {
+ type OutboundDeliveryResult,
type MessagePollResult,
type MessageSendResult,
sendMessage,
@@ -75,9 +76,10 @@ function logSendResult(
runtime: RuntimeEnv,
) {
if (result.via === "direct") {
+ const directResult = result.result as OutboundDeliveryResult | undefined;
const summary = formatOutboundDeliverySummary(
result.provider,
- result.result,
+ directResult,
);
runtime.log(success(summary));
if (opts.json) {
@@ -87,7 +89,7 @@ function logSendResult(
provider: result.provider,
via: "direct",
to: opts.to,
- result: result.result,
+ result: directResult,
mediaUrl: opts.media ?? null,
}),
null,
@@ -153,7 +155,16 @@ export async function messageSendCommand(
gifPlayback: opts.gifPlayback,
accountId: opts.account,
dryRun: opts.dryRun,
- deps,
+ deps: deps
+ ? {
+ sendWhatsApp: deps.sendMessageWhatsApp,
+ sendTelegram: deps.sendMessageTelegram,
+ sendDiscord: deps.sendMessageDiscord,
+ sendSlack: deps.sendMessageSlack,
+ sendSignal: deps.sendMessageSignal,
+ sendIMessage: deps.sendMessageIMessage,
+ }
+ : undefined,
gateway: { clientName: "cli", mode: "cli" },
}),
);
From a4d6638f8910d9aa87f014cb49f4b868eb2b7cd3 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 07:54:52 +0100
Subject: [PATCH 024/220] fix: import outbound delivery types
---
src/commands/message.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/commands/message.ts b/src/commands/message.ts
index 97e83319f..220c9bc08 100644
--- a/src/commands/message.ts
+++ b/src/commands/message.ts
@@ -8,8 +8,8 @@ import {
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
+import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import {
- type OutboundDeliveryResult,
type MessagePollResult,
type MessageSendResult,
sendMessage,
From 77d4bb8dfe1bbbb43e40e7fa1447d624d4369b53 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 08:12:48 +0100
Subject: [PATCH 025/220] feat: update token auth flow
---
docs/start/wizard.md | 2 +-
src/commands/auth-choice-options.test.ts | 5 +-
src/commands/auth-choice-options.ts | 12 +--
src/commands/auth-choice.ts | 121 ++++++++--------------
src/commands/auth-token.ts | 37 +++++++
src/commands/configure.ts | 126 ++++++++++-------------
src/commands/message.ts | 2 +-
7 files changed, 145 insertions(+), 160 deletions(-)
create mode 100644 src/commands/auth-token.ts
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index cee53f260..00072c9f1 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -71,7 +71,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
2) **Model/Auth**
- **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- - **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
+ - **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts
index e0e95cbe7..63f3c4d2a 100644
--- a/src/commands/auth-choice-options.test.ts
+++ b/src/commands/auth-choice-options.test.ts
@@ -38,10 +38,9 @@ describe("buildAuthChoiceOptions", () => {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
- type: "oauth",
+ type: "token",
provider: "anthropic",
- access: "token",
- refresh: "refresh",
+ token: "token",
expires: Date.now() + 60 * 60 * 1000,
},
},
diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts
index a5bd2aa29..51c1f2c86 100644
--- a/src/commands/auth-choice-options.ts
+++ b/src/commands/auth-choice-options.ts
@@ -76,9 +76,9 @@ export function buildAuthChoiceOptions(params: {
}
options.push({
- value: "oauth",
- label: "Anthropic token (setup-token)",
- hint: "Runs `claude setup-token`",
+ value: "token",
+ label: "Anthropic token (paste setup-token)",
+ hint: "Run `claude setup-token`, then paste the token",
});
options.push({
@@ -91,11 +91,7 @@ export function buildAuthChoiceOptions(params: {
});
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({ value: "apiKey", label: "Anthropic API key" });
- options.push({
- value: "token",
- label: "Paste token (advanced)",
- hint: "Stores as a non-refreshable token profile",
- });
+ // Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" });
diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts
index 45bf5cf9a..ad48c1b36 100644
--- a/src/commands/auth-choice.ts
+++ b/src/commands/auth-choice.ts
@@ -17,10 +17,7 @@ import {
resolveEnvApiKey,
} from "../agents/model-auth.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
-import {
- normalizeProviderId,
- resolveConfiguredModelRef,
-} from "../agents/model-selection.js";
+import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -29,6 +26,10 @@ import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
+import {
+ buildTokenProfileId,
+ validateAnthropicSetupToken,
+} from "./auth-token.js";
import {
applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL,
@@ -136,65 +137,7 @@ export async function applyAuthChoice(params: {
);
};
- if (params.authChoice === "oauth") {
- await params.prompter.note(
- [
- "This will run `claude setup-token` to create a long-lived Anthropic token.",
- "Requires an interactive TTY and a Claude Pro/Max subscription.",
- ].join("\n"),
- "Anthropic token",
- );
-
- if (!process.stdin.isTTY) {
- await params.prompter.note(
- "`claude setup-token` requires an interactive TTY.",
- "Anthropic token",
- );
- return { config: nextConfig, agentModelOverride };
- }
-
- const proceed = await params.prompter.confirm({
- message: "Run `claude setup-token` now?",
- initialValue: true,
- });
- if (!proceed) return { config: nextConfig, agentModelOverride };
-
- const res = await (async () => {
- const { spawnSync } = await import("node:child_process");
- return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
- })();
- if (res.error) {
- await params.prompter.note(
- `Failed to run claude: ${String(res.error)}`,
- "Anthropic token",
- );
- return { config: nextConfig, agentModelOverride };
- }
- if (typeof res.status === "number" && res.status !== 0) {
- await params.prompter.note(
- `claude setup-token failed (exit ${res.status})`,
- "Anthropic token",
- );
- return { config: nextConfig, agentModelOverride };
- }
-
- const store = ensureAuthProfileStore(params.agentDir, {
- allowKeychainPrompt: true,
- });
- if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
- await params.prompter.note(
- `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
- "Anthropic token",
- );
- return { config: nextConfig, agentModelOverride };
- }
-
- nextConfig = applyAuthProfileConfig(nextConfig, {
- profileId: CLAUDE_CLI_PROFILE_ID,
- provider: "anthropic",
- mode: "token",
- });
- } else if (params.authChoice === "claude-cli") {
+ if (params.authChoice === "claude-cli") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
@@ -266,24 +209,50 @@ export async function applyAuthChoice(params: {
provider: "anthropic",
mode: "token",
});
- } else if (params.authChoice === "token") {
- const providerRaw = await params.prompter.text({
- message: "Token provider id (e.g. anthropic)",
- validate: (value) => (value?.trim() ? undefined : "Required"),
+ } else if (params.authChoice === "token" || params.authChoice === "oauth") {
+ const profileNameRaw = await params.prompter.text({
+ message: "Token name (blank = default)",
+ placeholder: "default",
+ });
+ const provider = (await params.prompter.select({
+ message: "Token provider",
+ options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
+ })) as "anthropic";
+ const profileId = buildTokenProfileId({
+ provider,
+ name: String(profileNameRaw ?? ""),
});
- const provider = normalizeProviderId(String(providerRaw).trim());
- const defaultProfileId = `${provider}:manual`;
- const profileIdRaw = await params.prompter.text({
- message: "Auth profile id",
- initialValue: defaultProfileId,
- validate: (value) => (value?.trim() ? undefined : "Required"),
+ const store = ensureAuthProfileStore(params.agentDir, {
+ allowKeychainPrompt: false,
});
- const profileId = String(profileIdRaw).trim();
+ const existing = store.profiles[profileId];
+ if (existing?.type === "token") {
+ const useExisting = await params.prompter.confirm({
+ message: `Use existing token "${profileId}"?`,
+ initialValue: true,
+ });
+ if (useExisting) {
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId,
+ provider,
+ mode: "token",
+ });
+ return { config: nextConfig, agentModelOverride };
+ }
+ }
+
+ await params.prompter.note(
+ [
+ "Run `claude setup-token` in your terminal.",
+ "Then paste the generated token below.",
+ ].join("\n"),
+ "Anthropic token",
+ );
const tokenRaw = await params.prompter.text({
- message: `Paste token for ${provider}`,
- validate: (value) => (value?.trim() ? undefined : "Required"),
+ message: "Paste Anthropic setup-token",
+ validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenRaw).trim();
diff --git a/src/commands/auth-token.ts b/src/commands/auth-token.ts
new file mode 100644
index 000000000..4c51b1382
--- /dev/null
+++ b/src/commands/auth-token.ts
@@ -0,0 +1,37 @@
+import { normalizeProviderId } from "../agents/model-selection.js";
+
+export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-";
+export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80;
+export const DEFAULT_TOKEN_PROFILE_NAME = "default";
+
+export function normalizeTokenProfileName(raw: string): string {
+ const trimmed = raw.trim();
+ if (!trimmed) return DEFAULT_TOKEN_PROFILE_NAME;
+ const slug = trimmed
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return slug || DEFAULT_TOKEN_PROFILE_NAME;
+}
+
+export function buildTokenProfileId(params: {
+ provider: string;
+ name: string;
+}): string {
+ const provider = normalizeProviderId(params.provider);
+ const name = normalizeTokenProfileName(params.name);
+ return `${provider}:${name}`;
+}
+
+export function validateAnthropicSetupToken(raw: string): string | undefined {
+ const trimmed = raw.trim();
+ if (!trimmed) return "Required";
+ if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) {
+ return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`;
+ }
+ if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) {
+ return "Token looks too short; paste the full setup-token";
+ }
+ return undefined;
+}
diff --git a/src/commands/configure.ts b/src/commands/configure.ts
index 67371e9a7..5e4c33902 100644
--- a/src/commands/configure.ts
+++ b/src/commands/configure.ts
@@ -21,7 +21,6 @@ import {
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
-import { normalizeProviderId } from "../agents/model-selection.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { createCliProgress } from "../cli/progress.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -47,6 +46,10 @@ import {
loginAntigravityVpsAware,
} from "./antigravity-oauth.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
+import {
+ buildTokenProfileId,
+ validateAnthropicSetupToken,
+} from "./auth-token.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
@@ -315,62 +318,7 @@ async function promptAuthConfig(
let next = cfg;
- if (authChoice === "oauth") {
- note(
- [
- "This will run `claude setup-token` to create a long-lived Anthropic token.",
- "Requires an interactive TTY and a Claude Pro/Max subscription.",
- ].join("\n"),
- "Anthropic token",
- );
-
- if (!process.stdin.isTTY) {
- note(
- "`claude setup-token` requires an interactive TTY.",
- "Anthropic token",
- );
- return next;
- }
-
- const proceed = guardCancel(
- await confirm({
- message: "Run `claude setup-token` now?",
- initialValue: true,
- }),
- runtime,
- );
- if (!proceed) return next;
-
- const res = await (async () => {
- const { spawnSync } = await import("node:child_process");
- return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
- })();
- if (res.error) {
- note(`Failed to run claude: ${String(res.error)}`, "Anthropic token");
- return next;
- }
- if (typeof res.status === "number" && res.status !== 0) {
- note(`claude setup-token failed (exit ${res.status})`, "Anthropic token");
- return next;
- }
-
- const store = ensureAuthProfileStore(undefined, {
- allowKeychainPrompt: true,
- });
- if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
- note(
- `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
- "Anthropic token",
- );
- return next;
- }
-
- next = applyAuthProfileConfig(next, {
- profileId: CLAUDE_CLI_PROFILE_ID,
- provider: "anthropic",
- mode: "token",
- });
- } else if (authChoice === "claude-cli") {
+ if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
@@ -407,30 +355,66 @@ async function promptAuthConfig(
provider: "anthropic",
mode: "token",
});
- } else if (authChoice === "token") {
- const providerRaw = guardCancel(
+ } else if (authChoice === "token" || authChoice === "oauth") {
+ const profileNameRaw = guardCancel(
await text({
- message: "Token provider id (e.g. anthropic)",
- validate: (value) => (value?.trim() ? undefined : "Required"),
+ message: "Token name (blank = default)",
+ placeholder: "default",
}),
runtime,
);
- const provider = normalizeProviderId(String(providerRaw).trim());
- const defaultProfileId = `${provider}:manual`;
- const profileIdRaw = guardCancel(
- await text({
- message: "Auth profile id",
- initialValue: defaultProfileId,
- validate: (value) => (value?.trim() ? undefined : "Required"),
+
+ const provider = guardCancel(
+ await select({
+ message: "Token provider",
+ options: [
+ {
+ value: "anthropic",
+ label: "Anthropic (only supported)",
+ },
+ ],
}),
runtime,
+ ) as "anthropic";
+
+ const profileId = buildTokenProfileId({
+ provider,
+ name: String(profileNameRaw ?? ""),
+ });
+ const store = ensureAuthProfileStore(undefined, {
+ allowKeychainPrompt: false,
+ });
+ const existing = store.profiles[profileId];
+ if (existing?.type === "token") {
+ const useExisting = guardCancel(
+ await confirm({
+ message: `Use existing token "${profileId}"?`,
+ initialValue: true,
+ }),
+ runtime,
+ );
+ if (useExisting) {
+ next = applyAuthProfileConfig(next, {
+ profileId,
+ provider,
+ mode: "token",
+ });
+ return next;
+ }
+ }
+
+ note(
+ [
+ "Run `claude setup-token` in your terminal.",
+ "Then paste the generated token below.",
+ ].join("\n"),
+ "Anthropic token",
);
- const profileId = String(profileIdRaw).trim();
const tokenRaw = guardCancel(
await text({
- message: `Paste token for ${provider}`,
- validate: (value) => (value?.trim() ? undefined : "Required"),
+ message: "Paste Anthropic setup-token",
+ validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
}),
runtime,
);
diff --git a/src/commands/message.ts b/src/commands/message.ts
index 220c9bc08..e6279cc22 100644
--- a/src/commands/message.ts
+++ b/src/commands/message.ts
@@ -2,13 +2,13 @@ import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { success } from "../globals.js";
+import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
formatGatewaySummary,
formatOutboundDeliverySummary,
} from "../infra/outbound/format.js";
-import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
import {
type MessagePollResult,
type MessageSendResult,
From 721183e259f675d0ed2338bf64a11faabfd0ac36 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 08:27:17 +0100
Subject: [PATCH 026/220] feat: unify message cli and tools
---
AGENTS.md | 8 +-
CHANGELOG.md | 2 +-
README.md | 2 +-
docs/automation/poll.md | 25 +-
docs/cli/index.md | 35 +-
docs/cli/message.md | 210 +++++
docs/gateway/index.md | 2 +-
docs/index.md | 2 +-
docs/nodes/images.md | 6 +-
docs/providers/telegram.md | 2 +-
docs/providers/whatsapp.md | 2 +-
docs/start/faq.md | 2 +-
docs/start/getting-started.md | 2 +-
docs/tools/index.md | 83 +-
src/agents/clawdbot-tools.ts | 10 +-
src/agents/pi-tools.test.ts | 37 +-
src/agents/pi-tools.ts | 46 +-
src/agents/tool-display.json | 104 +-
src/agents/tools/message-tool.ts | 852 ++++++++++++++++-
src/agents/tools/slack-actions.ts | 2 +
src/agents/tools/slack-schema.ts | 1 +
src/cli/program.test.ts | 18 +-
src/cli/program.ts | 166 ++--
src/commands/message.test.ts | 331 ++-----
src/commands/message.ts | 1101 +++++++++++++++++++---
src/infra/outbound/provider-selection.ts | 113 +++
src/slack/actions.ts | 3 +-
27 files changed, 2391 insertions(+), 776 deletions(-)
create mode 100644 docs/cli/message.md
create mode 100644 src/infra/outbound/provider-selection.ts
diff --git a/AGENTS.md b/AGENTS.md
index 42088930c..f120b43ee 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,17 +93,17 @@
- Voice wake forwarding tips:
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
-- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
+- For manual `clawdbot message --action send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround
-The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
+The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message --action send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
-clawdbot message send --to "+1234" --message 'Hello!'
+clawdbot message --action send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
-clawdbot message send --to "+1234" --message "$(cat <<'EOF'
+clawdbot message --action send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9af15bc19..f4a9034ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
- Commands: accept /models as an alias for /model.
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
-- CLI: replace `send`/`poll` with `message send`/`message poll`, and add the `message` agent tool.
+- CLI: replace `message send`/`message poll` with `message --action ...`, and fold Discord/Slack/Telegram/WhatsApp tools into `message` (provider required unless only one configured).
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
diff --git a/README.md b/README.md
index 18ea6ca16..84cd78435 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ clawdbot onboard --install-daemon
clawdbot gateway --port 18789 --verbose
# Send a message
-clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
+clawdbot message --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high
diff --git a/docs/automation/poll.md b/docs/automation/poll.md
index 4aac3fa92..071b4a071 100644
--- a/docs/automation/poll.md
+++ b/docs/automation/poll.md
@@ -15,18 +15,22 @@ read_when:
```bash
# WhatsApp
-clawdbot message poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
-clawdbot message poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
+clawdbot message --action poll --to +15555550123 \
+ --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
+clawdbot message --action poll --to 123456789@g.us \
+ --poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord
-clawdbot message poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
-clawdbot message poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48
+clawdbot message --action poll --provider discord --to channel:123456789 \
+ --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
+clawdbot message --action poll --provider discord --to channel:123456789 \
+ --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
```
Options:
- `--provider`: `whatsapp` (default) or `discord`
-- `--max-selections`: how many choices a voter can select (default: 1)
-- `--duration-hours`: Discord-only (defaults to 24 when omitted)
+- `--poll-multi`: allow selecting multiple options
+- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted)
## Gateway RPC
@@ -45,10 +49,7 @@ Params:
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
-## Agent tool (Discord)
-The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`.
-
-Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect).
-
## Agent tool (Message)
-Use the `message` tool with `poll` action (`to`, `question`, `options`, optional `maxSelections`, `durationHours`, `provider`).
+Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `provider`).
+
+Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select.
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 99fb75d3c..28a91d6f3 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -56,8 +56,6 @@ clawdbot [--dev] [--profile ]
info
check
message
- send
- poll
agent
agents
list
@@ -285,37 +283,10 @@ Options:
## Messaging + agent
-### `message send`
-Send a message through a provider.
+### `message`
+Unified outbound messaging + provider actions.
-Required:
-- `--to `
-- `--message `
-
-Options:
-- `--media `
-- `--gif-playback`
-- `--provider `
-- `--account ` (WhatsApp)
-- `--dry-run`
-- `--json`
-- `--verbose`
-
-### `message poll`
-Create a poll (WhatsApp or Discord).
-
-Required:
-- `--to `
-- `--question `
-- `--option ` (repeat 2-12 times)
-
-Options:
-- `--max-selections `
-- `--duration-hours ` (Discord)
-- `--provider `
-- `--dry-run`
-- `--json`
-- `--verbose`
+See: [/cli/message](/cli/message)
### `agent`
Run one agent turn via the Gateway (or `--local` embedded).
diff --git a/docs/cli/message.md b/docs/cli/message.md
new file mode 100644
index 000000000..7ddc33fda
--- /dev/null
+++ b/docs/cli/message.md
@@ -0,0 +1,210 @@
+---
+summary: "CLI reference for `clawdbot message` (send + provider actions)"
+read_when:
+ - Adding or modifying message CLI actions
+ - Changing outbound provider behavior
+---
+
+# `clawdbot message`
+
+Single outbound command for sending messages and provider actions
+(Discord/Slack/Telegram/WhatsApp/Signal/iMessage).
+
+## Usage
+
+```
+clawdbot message --action [--provider ] [flags]
+```
+
+Defaults:
+- `--action send`
+
+Provider selection:
+- `--provider` required if more than one provider is configured.
+- If exactly one provider is configured, it becomes the default.
+- Values: `whatsapp|telegram|discord|slack|signal|imessage`
+
+Target formats (`--to`):
+- WhatsApp: E.164 or group JID
+- Telegram: chat id or `@username`
+- Discord/Slack: `channel:` or `user:` (raw id ok)
+- Signal: E.164, `group:`, or `signal:+E.164`
+- iMessage: handle or `chat_id:`
+
+## Common flags
+
+- `--to `
+- `--message `
+- `--media `
+- `--message-id `
+- `--reply-to `
+- `--thread-id ` (Telegram forum thread)
+- `--account ` (multi-account providers)
+- `--dry-run`
+- `--json`
+- `--verbose`
+
+## Actions
+
+### `send`
+Providers: whatsapp, telegram, discord, slack, signal, imessage
+Required: `--to`, `--message`
+Optional: `--media`, `--reply-to`, `--thread-id`, `--account`, `--gif-playback`
+
+### `react`
+Providers: discord, slack, telegram, whatsapp
+Required: `--to`, `--message-id`
+Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--account`
+
+### `reactions`
+Providers: discord, slack
+Required: `--to`, `--message-id`
+Optional: `--limit`
+
+### `read`
+Providers: discord, slack
+Required: `--to`
+Optional: `--limit`, `--before`, `--after`, `--around`
+
+### `edit`
+Providers: discord, slack
+Required: `--to`, `--message-id`, `--message`
+
+### `delete`
+Providers: discord, slack
+Required: `--to`, `--message-id`
+
+### `pin`
+Providers: discord, slack
+Required: `--to`, `--message-id`
+
+### `unpin`
+Providers: discord, slack
+Required: `--to`, `--message-id`
+
+### `list-pins`
+Providers: discord, slack
+Required: `--to`
+
+### `poll`
+Providers: whatsapp, discord
+Required: `--to`, `--poll-question`, `--poll-option` (repeat)
+Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
+
+### `sticker`
+Providers: discord
+Required: `--to`, `--sticker-id` (repeat)
+Optional: `--message`
+
+### `permissions`
+Providers: discord
+Required: `--to` (channel id)
+
+### `thread-create`
+Providers: discord
+Required: `--to` (channel id), `--thread-name`
+Optional: `--message-id`, `--auto-archive-min`
+
+### `thread-list`
+Providers: discord
+Required: `--guild-id`
+Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
+
+### `thread-reply`
+Providers: discord
+Required: `--to` (thread id), `--message`
+Optional: `--media`, `--reply-to`
+
+### `search`
+Providers: discord
+Required: `--guild-id`, `--query`
+Optional: `--channel-id`, `--channel-ids`, `--author-id`, `--author-ids`, `--limit`
+
+### `member-info`
+Providers: discord, slack
+Required: `--user-id`
+Discord only: also `--guild-id`
+
+### `role-info`
+Providers: discord
+Required: `--guild-id`
+
+### `emoji-list`
+Providers: discord, slack
+Discord only: `--guild-id`
+
+### `emoji-upload`
+Providers: discord
+Required: `--guild-id`, `--emoji-name`, `--media`
+Optional: `--role-ids` (repeat)
+
+### `sticker-upload`
+Providers: discord
+Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
+
+### `role-add`
+Providers: discord
+Required: `--guild-id`, `--user-id`, `--role-id`
+
+### `role-remove`
+Providers: discord
+Required: `--guild-id`, `--user-id`, `--role-id`
+
+### `channel-info`
+Providers: discord
+Required: `--channel-id`
+
+### `channel-list`
+Providers: discord
+Required: `--guild-id`
+
+### `voice-status`
+Providers: discord
+Required: `--guild-id`, `--user-id`
+
+### `event-list`
+Providers: discord
+Required: `--guild-id`
+
+### `event-create`
+Providers: discord
+Required: `--guild-id`, `--event-name`, `--start-time`
+Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type`
+
+### `timeout`
+Providers: discord
+Required: `--guild-id`, `--user-id`
+Optional: `--duration-min`, `--until`, `--reason`
+
+### `kick`
+Providers: discord
+Required: `--guild-id`, `--user-id`
+Optional: `--reason`
+
+### `ban`
+Providers: discord
+Required: `--guild-id`, `--user-id`
+Optional: `--reason`, `--delete-days`
+
+## Examples
+
+Send a Discord reply:
+```
+clawdbot message --action send --provider discord \
+ --to channel:123 --message "hi" --reply-to 456
+```
+
+Create a Discord poll:
+```
+clawdbot message --action poll --provider discord \
+ --to channel:123 \
+ --poll-question "Snack?" \
+ --poll-option Pizza --poll-option Sushi \
+ --poll-multi --poll-duration-hours 48
+```
+
+React in Slack:
+```
+clawdbot message --action react --provider slack \
+ --to C123 --message-id 456 --emoji "✅"
+```
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index 9b2e3dcf2..712ed6246 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -254,7 +254,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
-- `clawdbot message send --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
+- `clawdbot message --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to ` — run an agent turn (waits for final by default).
- `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
diff --git a/docs/index.md b/docs/index.md
index 35332ecf1..e01f53e61 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -134,7 +134,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway):
```bash
-clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT"
+clawdbot message --to +15555550123 --message "Hello from CLAWDBOT"
```
## Configuration (optional)
diff --git a/docs/nodes/images.md b/docs/nodes/images.md
index 84c1a3008..8235cc992 100644
--- a/docs/nodes/images.md
+++ b/docs/nodes/images.md
@@ -8,12 +8,12 @@ read_when:
CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies.
## Goals
-- Send media with optional captions via `clawdbot message send --media`.
+- Send media with optional captions via `clawdbot message --media`.
- Allow auto-replies from the web inbox to include media alongside text.
- Keep per-type limits sane and predictable.
## CLI Surface
-- `clawdbot message send --media [--message ]`
+- `clawdbot message --media [--message ]`
- `--media` optional; caption can be empty for media-only sends.
- `--dry-run` prints the resolved payload; `--json` emits `{ provider, to, messageId, mediaUrl, caption }`.
@@ -30,7 +30,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
## Auto-Reply Pipeline
- `getReplyFromConfig` returns `{ text?, mediaUrl?, mediaUrls? }`.
-- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message send`.
+- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message`.
- Multiple media entries are sent sequentially if provided.
## Inbound Media to Commands (Pi)
diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md
index 42cf31cf2..b9f14f1ad 100644
--- a/docs/providers/telegram.md
+++ b/docs/providers/telegram.md
@@ -223,7 +223,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
-- Example: `clawdbot message send --provider telegram --to 123456789 --message "hi"`.
+- Example: `clawdbot message --provider telegram --to 123456789 --message "hi"`.
## Troubleshooting
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index 4cec0dc62..ecb9ee190 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -158,7 +158,7 @@ Behavior:
- Caption only on first media item.
- Media fetch supports HTTP(S) and local paths.
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
- - CLI: `clawdbot message send --media --gif-playback`
+ - CLI: `clawdbot message --media --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
## Media limits + optimization
diff --git a/docs/start/faq.md b/docs/start/faq.md
index 06be764b1..aecd25afb 100644
--- a/docs/start/faq.md
+++ b/docs/start/faq.md
@@ -560,7 +560,7 @@ Outbound attachments from the agent must include a `MEDIA:` line (o
CLI sending:
```bash
-clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
+clawdbot message --to +15555550123 --message "Here you go" --media /path/to/file.png
```
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md
index f81d70a20..8bc36d54f 100644
--- a/docs/start/getting-started.md
+++ b/docs/start/getting-started.md
@@ -152,7 +152,7 @@ In a new terminal:
```bash
clawdbot health
-clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
+clawdbot message --to +15555550123 --message "Hello from Clawdbot"
```
If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 1835a74ac..aa663a0ea 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -149,15 +149,28 @@ Notes:
- Uses the image model directly (independent of the main chat model).
### `message`
-Send messages and polls across providers.
+Send messages and provider actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage.
Core actions:
- `send` (text + optional media)
- `poll` (WhatsApp/Discord polls)
+- `react` / `reactions` / `read` / `edit` / `delete`
+- `pin` / `unpin` / `list-pins`
+- `permissions`
+- `thread-create` / `thread-list` / `thread-reply`
+- `search`
+- `sticker`
+- `member-info` / `role-info`
+- `emoji-list` / `emoji-upload` / `sticker-upload`
+- `role-add` / `role-remove`
+- `channel-info` / `channel-list`
+- `voice-status`
+- `event-list` / `event-create`
+- `timeout` / `kick` / `ban`
Notes:
-- `send` routes WhatsApp via the Gateway and other providers directly.
-- `poll` always routes via the Gateway.
+- `send` routes WhatsApp via the Gateway; other providers go direct.
+- `poll` uses the Gateway for WhatsApp and direct Discord API for Discord.
### `cron`
Manage Gateway cron jobs and wakeups.
@@ -209,70 +222,6 @@ Notes:
- Result is restricted to per-agent allowlists (`routing.agents..subagents.allowAgents`).
- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`.
-### `discord`
-Send Discord reactions, stickers, or polls.
-
-Core actions:
-- `react` (`channelId`, `messageId`, `emoji`)
-- `reactions` (`channelId`, `messageId`, optional `limit`)
-- `sticker` (`to`, `stickerIds`, optional `content`)
-- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`)
-- `permissions` (`channelId`)
-- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`)
-- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`)
-- `editMessage` (`channelId`, `messageId`, `content`)
-- `deleteMessage` (`channelId`, `messageId`)
-- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`)
-- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`)
-- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`)
-- `pinMessage`/`unpinMessage` (`channelId`, `messageId`)
-- `listPins` (`channelId`)
-- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`)
-- `memberInfo` (`guildId`, `userId`)
-- `roleInfo` (`guildId`)
-- `emojiList` (`guildId`)
-- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`)
-- `channelInfo` (`channelId`)
-- `channelList` (`guildId`)
-- `voiceStatus` (`guildId`, `userId`)
-- `eventList` (`guildId`)
-- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`)
-- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`)
-- `kick` (`guildId`, `userId`, optional `reason`)
-- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`)
-
-Notes:
-- `to` accepts `channel:` or `user:`.
-- Polls require 2–10 answers and default to 24 hours.
-- `reactions` returns per-emoji user lists (limited to 100 per reaction).
-- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
-- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
-- `searchMessages` follows the Discord preview feature constraints (limit max 25, channel/author filters accept arrays).
-- The tool is only exposed when the current provider is Discord.
-
-### `whatsapp`
-Send WhatsApp reactions.
-
-Core actions:
-- `react` (`chatJid`, `messageId`, `emoji`, optional `remove`, `participant`, `fromMe`, `accountId`)
-
-Notes:
-- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
-- `whatsapp.actions.*` gates WhatsApp tool actions.
-- The tool is only exposed when the current provider is WhatsApp.
-
-### `telegram`
-Send Telegram messages or reactions.
-
-Core actions:
-- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyToMessageId`, `messageThreadId`)
-- `react` (`chatId`, `messageId`, `emoji`, optional `remove`)
-
-Notes:
-- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
-- `telegram.actions.*` gates Telegram tool actions.
-- The tool is only exposed when the current provider is Telegram.
-
## Parameters (common)
Gateway-backed tools (`canvas`, `nodes`, `cron`):
diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts
index 40f647f16..f63b6e787 100644
--- a/src/agents/clawdbot-tools.ts
+++ b/src/agents/clawdbot-tools.ts
@@ -4,7 +4,6 @@ import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
-import { createDiscordTool } from "./tools/discord-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
@@ -13,9 +12,6 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
-import { createSlackTool } from "./tools/slack-tool.js";
-import { createTelegramTool } from "./tools/telegram-tool.js";
-import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -35,14 +31,10 @@ export function createClawdbotTools(options?: {
createCanvasTool(),
createNodesTool(),
createCronTool(),
- createDiscordTool(),
- createMessageTool(),
- createSlackTool({
+ createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
- createTelegramTool(),
- createWhatsAppTool(),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts
index de754771c..f6250d1b3 100644
--- a/src/agents/pi-tools.test.ts
+++ b/src/agents/pi-tools.test.ts
@@ -141,36 +141,13 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
- it("scopes discord tool to discord provider", () => {
- const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
- expect(other.some((tool) => tool.name === "discord")).toBe(false);
-
- const discord = createClawdbotCodingTools({ messageProvider: "discord" });
- expect(discord.some((tool) => tool.name === "discord")).toBe(true);
- });
-
- it("scopes slack tool to slack provider", () => {
- const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
- expect(other.some((tool) => tool.name === "slack")).toBe(false);
-
- const slack = createClawdbotCodingTools({ messageProvider: "slack" });
- expect(slack.some((tool) => tool.name === "slack")).toBe(true);
- });
-
- it("scopes telegram tool to telegram provider", () => {
- const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
- expect(other.some((tool) => tool.name === "telegram")).toBe(false);
-
- const telegram = createClawdbotCodingTools({ messageProvider: "telegram" });
- expect(telegram.some((tool) => tool.name === "telegram")).toBe(true);
- });
-
- it("scopes whatsapp tool to whatsapp provider", () => {
- const other = createClawdbotCodingTools({ messageProvider: "slack" });
- expect(other.some((tool) => tool.name === "whatsapp")).toBe(false);
-
- const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" });
- expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true);
+ it("does not expose provider-specific message tools", () => {
+ const tools = createClawdbotCodingTools({ messageProvider: "discord" });
+ const names = new Set(tools.map((tool) => tool.name));
+ expect(names.has("discord")).toBe(false);
+ expect(names.has("slack")).toBe(false);
+ expect(names.has("telegram")).toBe(false);
+ expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index b42133824..11e8c491b 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -613,37 +613,6 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
-function normalizeMessageProvider(
- messageProvider?: string,
-): string | undefined {
- const trimmed = messageProvider?.trim().toLowerCase();
- return trimmed ? trimmed : undefined;
-}
-
-function shouldIncludeDiscordTool(messageProvider?: string): boolean {
- const normalized = normalizeMessageProvider(messageProvider);
- if (!normalized) return false;
- return normalized === "discord" || normalized.startsWith("discord:");
-}
-
-function shouldIncludeSlackTool(messageProvider?: string): boolean {
- const normalized = normalizeMessageProvider(messageProvider);
- if (!normalized) return false;
- return normalized === "slack" || normalized.startsWith("slack:");
-}
-
-function shouldIncludeTelegramTool(messageProvider?: string): boolean {
- const normalized = normalizeMessageProvider(messageProvider);
- if (!normalized) return false;
- return normalized === "telegram" || normalized.startsWith("telegram:");
-}
-
-function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
- const normalized = normalizeMessageProvider(messageProvider);
- if (!normalized) return false;
- return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
-}
-
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
@@ -724,20 +693,9 @@ export function createClawdbotCodingTools(options?: {
config: options?.config,
}),
];
- const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
- const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
- const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider);
- const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
- const filtered = tools.filter((tool) => {
- if (tool.name === "discord") return allowDiscord;
- if (tool.name === "slack") return allowSlack;
- if (tool.name === "telegram") return allowTelegram;
- if (tool.name === "whatsapp") return allowWhatsApp;
- return true;
- });
const toolsFiltered = effectiveToolsPolicy
- ? filterToolsByPolicy(filtered, effectiveToolsPolicy)
- : filtered;
+ ? filterToolsByPolicy(tools, effectiveToolsPolicy)
+ : tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: toolsFiltered;
diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json
index 6db12608e..71ce6da81 100644
--- a/src/agents/tool-display.json
+++ b/src/agents/tool-display.json
@@ -154,8 +154,37 @@
"emoji": "✉️",
"title": "Message",
"actions": {
- "send": { "label": "send", "detailKeys": ["to", "provider", "mediaUrl"] },
- "poll": { "label": "poll", "detailKeys": ["to", "provider", "question"] }
+ "send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] },
+ "poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] },
+ "react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] },
+ "reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] },
+ "read": { "label": "read", "detailKeys": ["provider", "to", "limit"] },
+ "edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] },
+ "delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] },
+ "pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] },
+ "unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] },
+ "list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] },
+ "permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] },
+ "thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] },
+ "thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] },
+ "thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] },
+ "search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] },
+ "sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] },
+ "member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] },
+ "role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] },
+ "emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] },
+ "emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] },
+ "sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] },
+ "role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
+ "role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
+ "channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] },
+ "channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] },
+ "voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] },
+ "event-list": { "label": "events", "detailKeys": ["provider", "guildId"] },
+ "event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] },
+ "timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] },
+ "kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] },
+ "ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] }
}
},
"agents_list": {
@@ -190,77 +219,6 @@
"start": { "label": "start" },
"wait": { "label": "wait" }
}
- },
- "discord": {
- "emoji": "💬",
- "title": "Discord",
- "actions": {
- "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
- "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
- "sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
- "poll": { "label": "poll", "detailKeys": ["question", "to"] },
- "permissions": { "label": "permissions", "detailKeys": ["channelId"] },
- "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
- "sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
- "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
- "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
- "threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
- "threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
- "threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
- "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
- "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
- "listPins": { "label": "list pins", "detailKeys": ["channelId"] },
- "searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
- "memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
- "roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
- "emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
- "emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
- "stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
- "roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
- "roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
- "channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
- "channelList": { "label": "channels", "detailKeys": ["guildId"] },
- "voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
- "eventList": { "label": "events", "detailKeys": ["guildId"] },
- "eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
- "timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
- "kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
- "ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
- }
- },
- "slack": {
- "emoji": "💬",
- "title": "Slack",
- "actions": {
- "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
- "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
- "sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
- "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
- "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
- "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
- "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
- "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
- "listPins": { "label": "list pins", "detailKeys": ["channelId"] },
- "memberInfo": { "label": "member", "detailKeys": ["userId"] },
- "emojiList": { "label": "emoji list" }
- }
- },
- "telegram": {
- "emoji": "✈️",
- "title": "Telegram",
- "actions": {
- "react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] }
- }
- },
- "whatsapp": {
- "emoji": "💬",
- "title": "WhatsApp",
- "actions": {
- "react": {
- "label": "react",
- "detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"]
- }
- }
}
}
}
diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts
index 29261cb1b..a4d54b2e9 100644
--- a/src/agents/tools/message-tool.ts
+++ b/src/agents/tools/message-tool.ts
@@ -1,11 +1,15 @@
import { Type } from "@sinclair/typebox";
+import type { ClawdbotConfig } from "../../config/config.js";
+import { loadConfig } from "../../config/config.js";
import {
type MessagePollResult,
type MessageSendResult,
sendMessage,
sendPoll,
} from "../../infra/outbound/message.js";
+import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
+import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
@@ -13,36 +17,131 @@ import {
readStringArrayParam,
readStringParam,
} from "./common.js";
+import { handleDiscordAction } from "./discord-actions.js";
+import { handleSlackAction } from "./slack-actions.js";
+import { handleTelegramAction } from "./telegram-actions.js";
+import { handleWhatsAppAction } from "./whatsapp-actions.js";
+
+const MessageActionSchema = Type.Union([
+ Type.Literal("send"),
+ Type.Literal("poll"),
+ Type.Literal("react"),
+ Type.Literal("reactions"),
+ Type.Literal("read"),
+ Type.Literal("edit"),
+ Type.Literal("delete"),
+ Type.Literal("pin"),
+ Type.Literal("unpin"),
+ Type.Literal("list-pins"),
+ Type.Literal("permissions"),
+ Type.Literal("thread-create"),
+ Type.Literal("thread-list"),
+ Type.Literal("thread-reply"),
+ Type.Literal("search"),
+ Type.Literal("sticker"),
+ Type.Literal("member-info"),
+ Type.Literal("role-info"),
+ Type.Literal("emoji-list"),
+ Type.Literal("emoji-upload"),
+ Type.Literal("sticker-upload"),
+ Type.Literal("role-add"),
+ Type.Literal("role-remove"),
+ Type.Literal("channel-info"),
+ Type.Literal("channel-list"),
+ Type.Literal("voice-status"),
+ Type.Literal("event-list"),
+ Type.Literal("event-create"),
+ Type.Literal("timeout"),
+ Type.Literal("kick"),
+ Type.Literal("ban"),
+]);
const MessageToolSchema = Type.Object({
- action: Type.Union([Type.Literal("send"), Type.Literal("poll")]),
- to: Type.Optional(Type.String()),
- content: Type.Optional(Type.String()),
- mediaUrl: Type.Optional(Type.String()),
- gifPlayback: Type.Optional(Type.Boolean()),
+ action: MessageActionSchema,
provider: Type.Optional(Type.String()),
+ to: Type.Optional(Type.String()),
+ message: Type.Optional(Type.String()),
+ media: Type.Optional(Type.String()),
+ messageId: Type.Optional(Type.String()),
+ replyTo: Type.Optional(Type.String()),
+ threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
- question: Type.Optional(Type.String()),
- options: Type.Optional(Type.Array(Type.String())),
- maxSelections: Type.Optional(Type.Number()),
- durationHours: Type.Optional(Type.Number()),
+ gifPlayback: Type.Optional(Type.Boolean()),
+ emoji: Type.Optional(Type.String()),
+ remove: Type.Optional(Type.Boolean()),
+ limit: Type.Optional(Type.Number()),
+ before: Type.Optional(Type.String()),
+ after: Type.Optional(Type.String()),
+ around: Type.Optional(Type.String()),
+ pollQuestion: Type.Optional(Type.String()),
+ pollOption: Type.Optional(Type.Array(Type.String())),
+ pollDurationHours: Type.Optional(Type.Number()),
+ pollMulti: Type.Optional(Type.Boolean()),
+ channelId: Type.Optional(Type.String()),
+ channelIds: Type.Optional(Type.Array(Type.String())),
+ guildId: Type.Optional(Type.String()),
+ userId: Type.Optional(Type.String()),
+ authorId: Type.Optional(Type.String()),
+ authorIds: Type.Optional(Type.Array(Type.String())),
+ roleId: Type.Optional(Type.String()),
+ roleIds: Type.Optional(Type.Array(Type.String())),
+ emojiName: Type.Optional(Type.String()),
+ stickerId: Type.Optional(Type.Array(Type.String())),
+ stickerName: Type.Optional(Type.String()),
+ stickerDesc: Type.Optional(Type.String()),
+ stickerTags: Type.Optional(Type.String()),
+ threadName: Type.Optional(Type.String()),
+ autoArchiveMin: Type.Optional(Type.Number()),
+ query: Type.Optional(Type.String()),
+ eventName: Type.Optional(Type.String()),
+ eventType: Type.Optional(Type.String()),
+ startTime: Type.Optional(Type.String()),
+ endTime: Type.Optional(Type.String()),
+ desc: Type.Optional(Type.String()),
+ location: Type.Optional(Type.String()),
+ durationMin: Type.Optional(Type.Number()),
+ until: Type.Optional(Type.String()),
+ reason: Type.Optional(Type.String()),
+ deleteDays: Type.Optional(Type.Number()),
+ includeArchived: Type.Optional(Type.Boolean()),
+ participant: Type.Optional(Type.String()),
+ fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
-export function createMessageTool(): AnyAgentTool {
+type MessageToolOptions = {
+ agentAccountId?: string;
+ config?: ClawdbotConfig;
+};
+
+function resolveAgentAccountId(value?: string): string | undefined {
+ const trimmed = value?.trim();
+ if (!trimmed) return undefined;
+ return normalizeAccountId(trimmed);
+}
+
+export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
+ const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Message",
name: "message",
description:
- "Send messages and polls across providers (send/poll). Prefer this for general outbound messaging.",
+ "Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage).",
parameters: MessageToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record;
+ const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { required: true });
+ const providerSelection = await resolveMessageProviderSelection({
+ cfg,
+ provider: readStringParam(params, "provider"),
+ });
+ const provider = providerSelection.provider;
+ const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
@@ -54,13 +153,13 @@ export function createMessageTool(): AnyAgentTool {
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
- const content = readStringParam(params, "content", {
+ const message = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
- const mediaUrl = readStringParam(params, "mediaUrl", { trim: false });
- const provider = readStringParam(params, "provider");
- const accountId = readStringParam(params, "accountId");
+ const mediaUrl = readStringParam(params, "media", { trim: false });
+ const replyTo = readStringParam(params, "replyTo");
+ const threadId = readStringParam(params, "threadId");
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
@@ -68,12 +167,66 @@ export function createMessageTool(): AnyAgentTool {
? params.bestEffort
: undefined;
+ if (dryRun) {
+ const result: MessageSendResult = await sendMessage({
+ to,
+ content: message,
+ mediaUrl: mediaUrl || undefined,
+ provider: provider || undefined,
+ accountId: accountId ?? undefined,
+ gifPlayback,
+ dryRun,
+ bestEffort,
+ gateway,
+ });
+ return jsonResult(result);
+ }
+
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "sendMessage",
+ to,
+ content: message,
+ mediaUrl: mediaUrl ?? undefined,
+ replyTo: replyTo ?? undefined,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ {
+ action: "sendMessage",
+ to,
+ content: message,
+ mediaUrl: mediaUrl ?? undefined,
+ accountId: accountId ?? undefined,
+ threadTs: threadId ?? replyTo ?? undefined,
+ },
+ cfg,
+ );
+ }
+ if (provider === "telegram") {
+ return await handleTelegramAction(
+ {
+ action: "sendMessage",
+ to,
+ content: message,
+ mediaUrl: mediaUrl ?? undefined,
+ replyToMessageId: replyTo ?? undefined,
+ messageThreadId: threadId ?? undefined,
+ },
+ cfg,
+ );
+ }
+
const result: MessageSendResult = await sendMessage({
to,
- content,
+ content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
- accountId: accountId || undefined,
+ accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
@@ -84,32 +237,679 @@ export function createMessageTool(): AnyAgentTool {
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
- const question = readStringParam(params, "question", {
+ const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
- readStringArrayParam(params, "options", { required: true }) ?? [];
- const maxSelections = readNumberParam(params, "maxSelections", {
+ readStringArrayParam(params, "pollOption", { required: true }) ?? [];
+ const allowMultiselect =
+ typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
+ const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
- const durationHours = readNumberParam(params, "durationHours", {
- integer: true,
- });
- const provider = readStringParam(params, "provider");
+ if (dryRun) {
+ const maxSelections = allowMultiselect
+ ? Math.max(2, options.length)
+ : 1;
+ const result: MessagePollResult = await sendPoll({
+ to,
+ question,
+ options,
+ maxSelections,
+ durationHours: durationHours ?? undefined,
+ provider,
+ dryRun,
+ gateway,
+ });
+ return jsonResult(result);
+ }
+
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "poll",
+ to,
+ question,
+ answers: options,
+ allowMultiselect,
+ durationHours: durationHours ?? undefined,
+ content: readStringParam(params, "message"),
+ },
+ cfg,
+ );
+ }
+
+ const maxSelections = allowMultiselect
+ ? Math.max(2, options.length)
+ : 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
- durationHours,
- provider: provider || undefined,
+ durationHours: durationHours ?? undefined,
+ provider,
dryRun,
gateway,
});
return jsonResult(result);
}
+ const resolveChannelId = (label: string) =>
+ readStringParam(params, label) ??
+ readStringParam(params, "to", { required: true });
+
+ const resolveChatId = (label: string) =>
+ readStringParam(params, label) ??
+ readStringParam(params, "to", { required: true });
+
+ if (action === "react") {
+ const messageId = readStringParam(params, "messageId", {
+ required: true,
+ });
+ const emoji = readStringParam(params, "emoji", { allowEmpty: true });
+ const remove =
+ typeof params.remove === "boolean" ? params.remove : undefined;
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "react",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ emoji,
+ remove,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ {
+ action: "react",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ emoji,
+ remove,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+ if (provider === "telegram") {
+ return await handleTelegramAction(
+ {
+ action: "react",
+ chatId: resolveChatId("chatId"),
+ messageId,
+ emoji,
+ remove,
+ },
+ cfg,
+ );
+ }
+ if (provider === "whatsapp") {
+ return await handleWhatsAppAction(
+ {
+ action: "react",
+ chatJid: resolveChatId("chatJid"),
+ messageId,
+ emoji,
+ remove,
+ participant: readStringParam(params, "participant"),
+ accountId: accountId ?? undefined,
+ fromMe:
+ typeof params.fromMe === "boolean" ? params.fromMe : undefined,
+ },
+ cfg,
+ );
+ }
+ throw new Error(`React is not supported for provider ${provider}.`);
+ }
+
+ if (action === "reactions") {
+ const messageId = readStringParam(params, "messageId", {
+ required: true,
+ });
+ const limit = readNumberParam(params, "limit", { integer: true });
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "reactions",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ limit,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ {
+ action: "reactions",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ limit,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+ throw new Error(
+ `Reactions are not supported for provider ${provider}.`,
+ );
+ }
+
+ if (action === "read") {
+ const limit = readNumberParam(params, "limit", { integer: true });
+ const before = readStringParam(params, "before");
+ const after = readStringParam(params, "after");
+ const around = readStringParam(params, "around");
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "readMessages",
+ channelId: resolveChannelId("channelId"),
+ limit,
+ before,
+ after,
+ around,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ {
+ action: "readMessages",
+ channelId: resolveChannelId("channelId"),
+ limit,
+ before,
+ after,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+ throw new Error(`Read is not supported for provider ${provider}.`);
+ }
+
+ if (action === "edit") {
+ const messageId = readStringParam(params, "messageId", {
+ required: true,
+ });
+ const message = readStringParam(params, "message", { required: true });
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "editMessage",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ content: message,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ {
+ action: "editMessage",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ content: message,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+ throw new Error(`Edit is not supported for provider ${provider}.`);
+ }
+
+ if (action === "delete") {
+ const messageId = readStringParam(params, "messageId", {
+ required: true,
+ });
+ if (provider === "discord") {
+ return await handleDiscordAction(
+ {
+ action: "deleteMessage",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ {
+ action: "deleteMessage",
+ channelId: resolveChannelId("channelId"),
+ messageId,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+ throw new Error(`Delete is not supported for provider ${provider}.`);
+ }
+
+ if (action === "pin" || action === "unpin" || action === "list-pins") {
+ const messageId =
+ action === "list-pins"
+ ? undefined
+ : readStringParam(params, "messageId", { required: true });
+ const channelId = resolveChannelId("channelId");
+ if (provider === "discord") {
+ const discordAction =
+ action === "pin"
+ ? "pinMessage"
+ : action === "unpin"
+ ? "unpinMessage"
+ : "listPins";
+ return await handleDiscordAction(
+ {
+ action: discordAction,
+ channelId,
+ messageId,
+ },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ const slackAction =
+ action === "pin"
+ ? "pinMessage"
+ : action === "unpin"
+ ? "unpinMessage"
+ : "listPins";
+ return await handleSlackAction(
+ {
+ action: slackAction,
+ channelId,
+ messageId,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+ throw new Error(`Pins are not supported for provider ${provider}.`);
+ }
+
+ if (action === "permissions") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Permissions are only supported for Discord (provider=${provider}).`,
+ );
+ }
+ return await handleDiscordAction(
+ {
+ action: "permissions",
+ channelId: resolveChannelId("channelId"),
+ },
+ cfg,
+ );
+ }
+
+ if (action === "thread-create") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Thread create is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const name = readStringParam(params, "threadName", { required: true });
+ const messageId = readStringParam(params, "messageId");
+ const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
+ integer: true,
+ });
+ return await handleDiscordAction(
+ {
+ action: "threadCreate",
+ channelId: resolveChannelId("channelId"),
+ name,
+ messageId,
+ autoArchiveMinutes,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "thread-list") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Thread list is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const channelId = readStringParam(params, "channelId");
+ const includeArchived =
+ typeof params.includeArchived === "boolean"
+ ? params.includeArchived
+ : undefined;
+ const before = readStringParam(params, "before");
+ const limit = readNumberParam(params, "limit", { integer: true });
+ return await handleDiscordAction(
+ {
+ action: "threadList",
+ guildId,
+ channelId,
+ includeArchived,
+ before,
+ limit,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "thread-reply") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Thread reply is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const content = readStringParam(params, "message", { required: true });
+ const mediaUrl = readStringParam(params, "media", { trim: false });
+ const replyTo = readStringParam(params, "replyTo");
+ return await handleDiscordAction(
+ {
+ action: "threadReply",
+ channelId: resolveChannelId("channelId"),
+ content,
+ mediaUrl: mediaUrl ?? undefined,
+ replyTo: replyTo ?? undefined,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "search") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Search is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const query = readStringParam(params, "query", { required: true });
+ const channelId = readStringParam(params, "channelId");
+ const channelIds = readStringArrayParam(params, "channelIds");
+ const authorId = readStringParam(params, "authorId");
+ const authorIds = readStringArrayParam(params, "authorIds");
+ const limit = readNumberParam(params, "limit", { integer: true });
+ return await handleDiscordAction(
+ {
+ action: "searchMessages",
+ guildId,
+ content: query,
+ channelId,
+ channelIds,
+ authorId,
+ authorIds,
+ limit,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "sticker") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Sticker send is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const stickerIds =
+ readStringArrayParam(params, "stickerId", {
+ required: true,
+ label: "sticker-id",
+ }) ?? [];
+ const content = readStringParam(params, "message");
+ return await handleDiscordAction(
+ {
+ action: "sticker",
+ to: readStringParam(params, "to", { required: true }),
+ stickerIds,
+ content,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "member-info") {
+ const userId = readStringParam(params, "userId", { required: true });
+ if (provider === "discord") {
+ const guildId = readStringParam(params, "guildId", {
+ required: true,
+ });
+ return await handleDiscordAction(
+ { action: "memberInfo", guildId, userId },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ { action: "memberInfo", userId, accountId: accountId ?? undefined },
+ cfg,
+ );
+ }
+ throw new Error(
+ `Member info is not supported for provider ${provider}.`,
+ );
+ }
+
+ if (action === "role-info") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Role info is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
+ }
+
+ if (action === "emoji-list") {
+ if (provider === "discord") {
+ const guildId = readStringParam(params, "guildId", {
+ required: true,
+ });
+ return await handleDiscordAction(
+ { action: "emojiList", guildId },
+ cfg,
+ );
+ }
+ if (provider === "slack") {
+ return await handleSlackAction(
+ { action: "emojiList", accountId: accountId ?? undefined },
+ cfg,
+ );
+ }
+ throw new Error(
+ `Emoji list is not supported for provider ${provider}.`,
+ );
+ }
+
+ if (action === "emoji-upload") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Emoji upload is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const name = readStringParam(params, "emojiName", { required: true });
+ const mediaUrl = readStringParam(params, "media", {
+ required: true,
+ trim: false,
+ });
+ const roleIds = readStringArrayParam(params, "roleIds");
+ return await handleDiscordAction(
+ {
+ action: "emojiUpload",
+ guildId,
+ name,
+ mediaUrl,
+ roleIds,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "sticker-upload") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Sticker upload is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const name = readStringParam(params, "stickerName", { required: true });
+ const description = readStringParam(params, "stickerDesc", {
+ required: true,
+ });
+ const tags = readStringParam(params, "stickerTags", { required: true });
+ const mediaUrl = readStringParam(params, "media", {
+ required: true,
+ trim: false,
+ });
+ return await handleDiscordAction(
+ {
+ action: "stickerUpload",
+ guildId,
+ name,
+ description,
+ tags,
+ mediaUrl,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "role-add" || action === "role-remove") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Role changes are only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const userId = readStringParam(params, "userId", { required: true });
+ const roleId = readStringParam(params, "roleId", { required: true });
+ const discordAction = action === "role-add" ? "roleAdd" : "roleRemove";
+ return await handleDiscordAction(
+ { action: discordAction, guildId, userId, roleId },
+ cfg,
+ );
+ }
+
+ if (action === "channel-info") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Channel info is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const channelId = readStringParam(params, "channelId", {
+ required: true,
+ });
+ return await handleDiscordAction(
+ { action: "channelInfo", channelId },
+ cfg,
+ );
+ }
+
+ if (action === "channel-list") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Channel list is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ return await handleDiscordAction(
+ { action: "channelList", guildId },
+ cfg,
+ );
+ }
+
+ if (action === "voice-status") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Voice status is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const userId = readStringParam(params, "userId", { required: true });
+ return await handleDiscordAction(
+ { action: "voiceStatus", guildId, userId },
+ cfg,
+ );
+ }
+
+ if (action === "event-list") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Event list is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ return await handleDiscordAction({ action: "eventList", guildId }, cfg);
+ }
+
+ if (action === "event-create") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Event create is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const name = readStringParam(params, "eventName", { required: true });
+ const startTime = readStringParam(params, "startTime", {
+ required: true,
+ });
+ const endTime = readStringParam(params, "endTime");
+ const description = readStringParam(params, "desc");
+ const channelId = readStringParam(params, "channelId");
+ const location = readStringParam(params, "location");
+ const entityType = readStringParam(params, "eventType");
+ return await handleDiscordAction(
+ {
+ action: "eventCreate",
+ guildId,
+ name,
+ startTime,
+ endTime,
+ description,
+ channelId,
+ location,
+ entityType,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "timeout" || action === "kick" || action === "ban") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Moderation actions are only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const guildId = readStringParam(params, "guildId", { required: true });
+ const userId = readStringParam(params, "userId", { required: true });
+ const durationMinutes = readNumberParam(params, "durationMin", {
+ integer: true,
+ });
+ const until = readStringParam(params, "until");
+ const reason = readStringParam(params, "reason");
+ const deleteMessageDays = readNumberParam(params, "deleteDays", {
+ integer: true,
+ });
+ const discordAction = action as "timeout" | "kick" | "ban";
+ return await handleDiscordAction(
+ {
+ action: discordAction,
+ guildId,
+ userId,
+ durationMinutes,
+ until,
+ reason,
+ deleteMessageDays,
+ },
+ cfg,
+ );
+ }
+
throw new Error(`Unknown action: ${action}`);
},
};
diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts
index 19a55121a..ae3d4c712 100644
--- a/src/agents/tools/slack-actions.ts
+++ b/src/agents/tools/slack-actions.ts
@@ -91,9 +91,11 @@ export async function handleSlackAction(
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
+ const threadTs = readStringParam(params, "threadTs");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
+ threadTs: threadTs ?? undefined,
});
return jsonResult({ ok: true, result });
}
diff --git a/src/agents/tools/slack-schema.ts b/src/agents/tools/slack-schema.ts
index a1afaf8bc..25ac504c6 100644
--- a/src/agents/tools/slack-schema.ts
+++ b/src/agents/tools/slack-schema.ts
@@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
+ threadTs: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts
index 6a215f582..3d44f0fbd 100644
--- a/src/cli/program.test.ts
+++ b/src/cli/program.test.ts
@@ -1,7 +1,7 @@
import * as fs from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
-const messageSendCommand = vi.fn();
+const messageCommand = vi.fn();
const statusCommand = vi.fn();
const configureCommand = vi.fn();
const setupCommand = vi.fn();
@@ -19,8 +19,7 @@ const runtime = {
};
vi.mock("../commands/message.js", () => ({
- messageSendCommand,
- messagePollCommand: vi.fn(),
+ messageCommand,
}));
vi.mock("../commands/status.js", () => ({ statusCommand }));
vi.mock("../commands/configure.js", () => ({ configureCommand }));
@@ -46,15 +45,12 @@ describe("cli program", () => {
vi.clearAllMocks();
});
- it("runs message send with required options", async () => {
+ it("runs message with required options", async () => {
const program = buildProgram();
- await program.parseAsync(
- ["message", "send", "--to", "+1", "--message", "hi"],
- {
- from: "user",
- },
- );
- expect(messageSendCommand).toHaveBeenCalled();
+ await program.parseAsync(["message", "--to", "+1", "--message", "hi"], {
+ from: "user",
+ });
+ expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {
diff --git a/src/cli/program.ts b/src/cli/program.ts
index d17f34bc2..1033ef145 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -8,7 +8,7 @@ import {
import { configureCommand } from "../commands/configure.js";
import { doctorCommand } from "../commands/doctor.js";
import { healthCommand } from "../commands/health.js";
-import { messagePollCommand, messageSendCommand } from "../commands/message.js";
+import { messageCommand } from "../commands/message.js";
import { onboardCommand } from "../commands/onboard.js";
import { sessionsCommand } from "../commands/sessions.js";
import { setupCommand } from "../commands/setup.js";
@@ -408,41 +408,100 @@ export function buildProgram() {
}
});
- const message = program
+ program
.command("message")
- .description("Send messages and polls across providers")
- .action(() => {
- message.outputHelp();
- defaultRuntime.error(
- danger('Missing subcommand. Try: "clawdbot message send"'),
- );
- defaultRuntime.exit(1);
- });
-
- message
- .command("send")
- .description(
- "Send a message (WhatsApp Web, Telegram bot, Discord, Slack, Signal, iMessage)",
+ .description("Send messages and provider actions")
+ .option(
+ "-a, --action ",
+ "Action: send|poll|react|reactions|read|edit|delete|pin|unpin|list-pins|permissions|thread-create|thread-list|thread-reply|search|sticker|member-info|role-info|emoji-list|emoji-upload|sticker-upload|role-add|role-remove|channel-info|channel-list|voice-status|event-list|event-create|timeout|kick|ban",
+ "send",
)
- .requiredOption(
- "-t, --to ",
- "Recipient: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord channel/user, or iMessage handle/chat_id",
+ .option(
+ "-t, --to ",
+ "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
)
- .requiredOption("-m, --message ", "Message body")
+ .option("-m, --message ", "Message body")
.option(
"--media ",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
+ .option("--message-id ", "Message id (edit/delete/react/pin)")
+ .option("--reply-to ", "Reply-to message id")
+ .option("--thread-id ", "Thread id (Telegram forum thread)")
+ .option("--account ", "Provider account id")
+ .option(
+ "--provider ",
+ "Provider: whatsapp|telegram|discord|slack|signal|imessage",
+ )
+ .option("--emoji ", "Emoji for reactions")
+ .option("--remove", "Remove reaction", false)
+ .option("--limit ", "Result limit for read/reactions/search")
+ .option("--before ", "Read/search before id")
+ .option("--after ", "Read/search after id")
+ .option("--around ", "Read around id (Discord)")
+ .option("--poll-question ", "Poll question")
+ .option(
+ "--poll-option ",
+ "Poll option (repeat 2-12 times)",
+ (value: string, previous: string[]) => previous.concat([value]),
+ [] as string[],
+ )
+ .option("--poll-multi", "Allow multiple selections", false)
+ .option("--poll-duration-hours ", "Poll duration (Discord)")
+ .option("--channel-id ", "Channel id")
+ .option(
+ "--channel-ids ",
+ "Channel id (repeat)",
+ (value: string, previous: string[]) => previous.concat([value]),
+ [] as string[],
+ )
+ .option("--guild-id ", "Guild id")
+ .option("--user-id ", "User id")
+ .option("--author-id ", "Author id")
+ .option(
+ "--author-ids ",
+ "Author id (repeat)",
+ (value: string, previous: string[]) => previous.concat([value]),
+ [] as string[],
+ )
+ .option("--role-id ", "Role id")
+ .option(
+ "--role-ids ",
+ "Role id (repeat)",
+ (value: string, previous: string[]) => previous.concat([value]),
+ [] as string[],
+ )
+ .option("--emoji-name ", "Emoji name")
+ .option(
+ "--sticker-id ",
+ "Sticker id (repeat)",
+ (value: string, previous: string[]) => previous.concat([value]),
+ [] as string[],
+ )
+ .option("--sticker-name ", "Sticker name")
+ .option("--sticker-desc ", "Sticker description")
+ .option("--sticker-tags ", "Sticker tags")
+ .option("--thread-name ", "Thread name")
+ .option("--auto-archive-min ", "Thread auto-archive minutes")
+ .option("--query ", "Search query")
+ .option("--event-name ", "Event name")
+ .option("--event-type ", "Event type")
+ .option("--start-time ", "Event start time")
+ .option("--end-time ", "Event end time")
+ .option("--desc ", "Event description")
+ .option("--location ", "Event location")
+ .option("--duration-min ", "Timeout duration minutes")
+ .option("--until ", "Timeout until")
+ .option("--reason ", "Moderation reason")
+ .option("--delete-days ", "Ban delete message days")
+ .option("--include-archived", "Include archived threads", false)
+ .option("--participant ", "WhatsApp reaction participant")
+ .option("--from-me", "WhatsApp reaction fromMe", false)
.option(
"--gif-playback",
"Treat video media as GIF playback (WhatsApp only).",
false,
)
- .option(
- "--provider ",
- "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)",
- )
- .option("--account ", "WhatsApp account id (accountId)")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.option("--verbose", "Verbose logging", false)
@@ -450,16 +509,16 @@ export function buildProgram() {
"after",
`
Examples:
- clawdbot message send --to +15555550123 --message "Hi"
- clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
- clawdbot message send --to +15555550123 --message "Hi" --dry-run # print payload only
- clawdbot message send --to +15555550123 --message "Hi" --json # machine-readable result`,
+ clawdbot message --to +15555550123 --message "Hi"
+ clawdbot message --action send --to +15555550123 --message "Hi" --media photo.jpg
+ clawdbot message --action poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
+ clawdbot message --action react --provider discord --to 123 --message-id 456 --emoji "✅"`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
- await messageSendCommand(
+ await messageCommand(
{
...opts,
account: opts.account as string | undefined,
@@ -473,55 +532,6 @@ Examples:
}
});
- message
- .command("poll")
- .description("Create a poll via WhatsApp or Discord")
- .requiredOption(
- "-t, --to ",
- "Recipient: WhatsApp JID/number or Discord channel/user",
- )
- .requiredOption("-q, --question ", "Poll question")
- .requiredOption(
- "-o, --option ",
- "Poll option (use multiple times, 2-12 required)",
- (value: string, previous: string[]) => previous.concat([value]),
- [] as string[],
- )
- .option(
- "-s, --max-selections ",
- "How many options can be selected (default: 1)",
- )
- .option(
- "--duration-hours ",
- "Poll duration in hours (Discord only, default: 24)",
- )
- .option(
- "--provider ",
- "Delivery provider: whatsapp|discord (default: whatsapp)",
- )
- .option("--dry-run", "Print payload and skip sending", false)
- .option("--json", "Output result as JSON", false)
- .option("--verbose", "Verbose logging", false)
- .addHelpText(
- "after",
- `
-Examples:
- clawdbot message poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe"
- clawdbot message poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2
- clawdbot message poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord
- clawdbot message poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`,
- )
- .action(async (opts) => {
- setVerbose(Boolean(opts.verbose));
- const deps = createDefaultDeps();
- try {
- await messagePollCommand(opts, deps, defaultRuntime);
- } catch (err) {
- defaultRuntime.error(String(err));
- defaultRuntime.exit(1);
- }
- });
-
program
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")
diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts
index 66dea2931..a93a3d4ca 100644
--- a/src/commands/message.test.ts
+++ b/src/commands/message.test.ts
@@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliDeps } from "../cli/deps.js";
import type { RuntimeEnv } from "../runtime.js";
-import { messagePollCommand, messageSendCommand } from "./message.js";
+import { messageCommand } from "./message.js";
let testConfig: Record = {};
vi.mock("../config/config.js", async (importOriginal) => {
@@ -19,13 +19,44 @@ vi.mock("../gateway/call.js", () => ({
randomIdempotencyKey: () => "idem-1",
}));
+const webAuthExists = vi.fn(async () => false);
+vi.mock("../web/session.js", () => ({
+ webAuthExists: (...args: unknown[]) => webAuthExists(...args),
+}));
+
+const handleDiscordAction = vi.fn(async () => ({ details: { ok: true } }));
+vi.mock("../agents/tools/discord-actions.js", () => ({
+ handleDiscordAction: (...args: unknown[]) => handleDiscordAction(...args),
+}));
+
+const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
+vi.mock("../agents/tools/slack-actions.js", () => ({
+ handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
+}));
+
+const handleTelegramAction = vi.fn(async () => ({ details: { ok: true } }));
+vi.mock("../agents/tools/telegram-actions.js", () => ({
+ handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
+}));
+
+const handleWhatsAppAction = vi.fn(async () => ({ details: { ok: true } }));
+vi.mock("../agents/tools/whatsapp-actions.js", () => ({
+ handleWhatsAppAction: (...args: unknown[]) => handleWhatsAppAction(...args),
+}));
+
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
beforeEach(() => {
- process.env.TELEGRAM_BOT_TOKEN = "token-abc";
- process.env.DISCORD_BOT_TOKEN = "token-discord";
+ process.env.TELEGRAM_BOT_TOKEN = "";
+ process.env.DISCORD_BOT_TOKEN = "";
testConfig = {};
+ callGatewayMock.mockReset();
+ webAuthExists.mockReset().mockResolvedValue(false);
+ handleDiscordAction.mockReset();
+ handleSlackAction.mockReset();
+ handleTelegramAction.mockReset();
+ handleWhatsAppAction.mockReset();
});
afterAll(() => {
@@ -51,26 +82,44 @@ const makeDeps = (overrides: Partial = {}): CliDeps => ({
...overrides,
});
-describe("messageSendCommand", () => {
- it("skips send on dry-run", async () => {
+describe("messageCommand", () => {
+ it("defaults provider when only one configured", async () => {
+ process.env.TELEGRAM_BOT_TOKEN = "token-abc";
const deps = makeDeps();
- await messageSendCommand(
+ await messageCommand(
{
- to: "+1",
+ to: "123",
message: "hi",
- dryRun: true,
},
deps,
runtime,
);
- expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
+ expect(handleTelegramAction).toHaveBeenCalled();
});
- it("sends via gateway", async () => {
+ it("requires provider when multiple configured", async () => {
+ process.env.TELEGRAM_BOT_TOKEN = "token-abc";
+ process.env.DISCORD_BOT_TOKEN = "token-discord";
+ const deps = makeDeps();
+ await expect(
+ messageCommand(
+ {
+ to: "123",
+ message: "hi",
+ },
+ deps,
+ runtime,
+ ),
+ ).rejects.toThrow(/Provider is required/);
+ });
+
+ it("sends via gateway for WhatsApp", async () => {
callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
const deps = makeDeps();
- await messageSendCommand(
+ await messageCommand(
{
+ action: "send",
+ provider: "whatsapp",
to: "+1",
message: "hi",
},
@@ -78,261 +127,27 @@ describe("messageSendCommand", () => {
runtime,
);
expect(callGatewayMock).toHaveBeenCalled();
- expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("g1"));
});
- it("does not override remote gateway URL", async () => {
- callGatewayMock.mockResolvedValueOnce({ messageId: "g2" });
- testConfig = {
- gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
- };
+ it("routes discord polls through message action", async () => {
const deps = makeDeps();
- await messageSendCommand(
+ await messageCommand(
{
- to: "+1",
- message: "hi",
- },
- deps,
- runtime,
- );
- const args = callGatewayMock.mock.calls.at(-1)?.[0] as
- | Record
- | undefined;
- expect(args?.url).toBeUndefined();
- });
-
- it("passes gifPlayback to gateway send", async () => {
- callGatewayMock.mockClear();
- callGatewayMock.mockResolvedValueOnce({ messageId: "g1" });
- const deps = makeDeps();
- await messageSendCommand(
- {
- to: "+1",
- message: "hi",
- gifPlayback: true,
- },
- deps,
- runtime,
- );
- expect(callGatewayMock).toHaveBeenCalledWith(
- expect.objectContaining({
- method: "send",
- params: expect.objectContaining({ gifPlayback: true }),
- }),
- );
- });
-
- it("routes to telegram provider", async () => {
- const deps = makeDeps({
- sendMessageTelegram: vi
- .fn()
- .mockResolvedValue({ messageId: "t1", chatId: "123" }),
- });
- testConfig = { telegram: { botToken: "token-abc" } };
- await messageSendCommand(
- { to: "123", message: "hi", provider: "telegram" },
- deps,
- runtime,
- );
- expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
- "123",
- "hi",
- expect.objectContaining({ accountId: undefined, verbose: false }),
- );
- expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
- });
-
- it("uses config token for telegram when env is missing", async () => {
- process.env.TELEGRAM_BOT_TOKEN = "";
- testConfig = { telegram: { botToken: "cfg-token" } };
- const deps = makeDeps({
- sendMessageTelegram: vi
- .fn()
- .mockResolvedValue({ messageId: "t1", chatId: "123" }),
- });
- await messageSendCommand(
- { to: "123", message: "hi", provider: "telegram" },
- deps,
- runtime,
- );
- expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
- "123",
- "hi",
- expect.objectContaining({ accountId: undefined, verbose: false }),
- );
- });
-
- it("routes to discord provider", async () => {
- const deps = makeDeps({
- sendMessageDiscord: vi
- .fn()
- .mockResolvedValue({ messageId: "d1", channelId: "chan" }),
- });
- await messageSendCommand(
- { to: "channel:chan", message: "hi", provider: "discord" },
- deps,
- runtime,
- );
- expect(deps.sendMessageDiscord).toHaveBeenCalledWith(
- "channel:chan",
- "hi",
- expect.objectContaining({ verbose: false }),
- );
- expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
- });
-
- it("routes to signal provider", async () => {
- const deps = makeDeps({
- sendMessageSignal: vi.fn().mockResolvedValue({ messageId: "s1" }),
- });
- await messageSendCommand(
- { to: "+15551234567", message: "hi", provider: "signal" },
- deps,
- runtime,
- );
- expect(deps.sendMessageSignal).toHaveBeenCalledWith(
- "+15551234567",
- "hi",
- expect.objectContaining({ maxBytes: undefined }),
- );
- expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
- });
-
- it("routes to slack provider", async () => {
- const deps = makeDeps({
- sendMessageSlack: vi
- .fn()
- .mockResolvedValue({ messageId: "s1", channelId: "C123" }),
- });
- await messageSendCommand(
- { to: "channel:C123", message: "hi", provider: "slack" },
- deps,
- runtime,
- );
- expect(deps.sendMessageSlack).toHaveBeenCalledWith(
- "channel:C123",
- "hi",
- expect.objectContaining({ accountId: undefined }),
- );
- expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
- });
-
- it("routes to imessage provider", async () => {
- const deps = makeDeps({
- sendMessageIMessage: vi.fn().mockResolvedValue({ messageId: "i1" }),
- });
- await messageSendCommand(
- { to: "chat_id:42", message: "hi", provider: "imessage" },
- deps,
- runtime,
- );
- expect(deps.sendMessageIMessage).toHaveBeenCalledWith(
- "chat_id:42",
- "hi",
- expect.objectContaining({ maxBytes: undefined }),
- );
- expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
- });
-
- it("emits json output", async () => {
- callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
- const deps = makeDeps();
- await messageSendCommand(
- {
- to: "+1",
- message: "hi",
- json: true,
- },
- deps,
- runtime,
- );
- expect(runtime.log).toHaveBeenCalledWith(
- expect.stringContaining('"provider": "whatsapp"'),
- );
- });
-});
-
-describe("messagePollCommand", () => {
- const deps: CliDeps = {
- sendMessageWhatsApp: vi.fn(),
- sendMessageTelegram: vi.fn(),
- sendMessageDiscord: vi.fn(),
- sendMessageSlack: vi.fn(),
- sendMessageSignal: vi.fn(),
- sendMessageIMessage: vi.fn(),
- };
-
- beforeEach(() => {
- callGatewayMock.mockReset();
- runtime.log.mockReset();
- runtime.error.mockReset();
- runtime.exit.mockReset();
- testConfig = {};
- });
-
- it("routes through gateway", async () => {
- callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
- await messagePollCommand(
- {
- to: "+1",
- question: "hi?",
- option: ["y", "n"],
- },
- deps,
- runtime,
- );
- expect(callGatewayMock).toHaveBeenCalledWith(
- expect.objectContaining({ method: "poll" }),
- );
- });
-
- it("does not override remote gateway URL", async () => {
- callGatewayMock.mockResolvedValueOnce({ messageId: "p1" });
- testConfig = {
- gateway: { mode: "remote", remote: { url: "wss://remote.example" } },
- };
- await messagePollCommand(
- {
- to: "+1",
- question: "hi?",
- option: ["y", "n"],
- },
- deps,
- runtime,
- );
- const args = callGatewayMock.mock.calls.at(-1)?.[0] as
- | Record
- | undefined;
- expect(args?.url).toBeUndefined();
- });
-
- it("emits json output with gateway metadata", async () => {
- callGatewayMock.mockResolvedValueOnce({ messageId: "p1", channelId: "C1" });
- await messagePollCommand(
- {
- to: "channel:C1",
- question: "hi?",
- option: ["y", "n"],
+ action: "poll",
provider: "discord",
- json: true,
+ to: "channel:123",
+ pollQuestion: "Snack?",
+ pollOption: ["Pizza", "Sushi"],
},
deps,
runtime,
);
- const lastLog = runtime.log.mock.calls.at(-1)?.[0] as string | undefined;
- expect(lastLog).toBeDefined();
- const payload = JSON.parse(lastLog ?? "{}") as Record;
- expect(payload).toMatchObject({
- provider: "discord",
- via: "gateway",
- to: "channel:C1",
- messageId: "p1",
- channelId: "C1",
- mediaUrl: null,
- question: "hi?",
- options: ["y", "n"],
- maxSelections: 1,
- durationHours: null,
- });
+ expect(handleDiscordAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "poll",
+ to: "channel:123",
+ }),
+ expect.any(Object),
+ );
});
});
diff --git a/src/commands/message.ts b/src/commands/message.ts
index e6279cc22..1ea310e8c 100644
--- a/src/commands/message.ts
+++ b/src/commands/message.ts
@@ -1,8 +1,16 @@
+import type { AgentToolResult } from "@mariozechner/pi-agent-core";
+import { handleDiscordAction } from "../agents/tools/discord-actions.js";
+import { handleSlackAction } from "../agents/tools/slack-actions.js";
+import { handleTelegramAction } from "../agents/tools/telegram-actions.js";
+import { handleWhatsAppAction } from "../agents/tools/whatsapp-actions.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { success } from "../globals.js";
-import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js";
+import type {
+ OutboundDeliveryResult,
+ OutboundSendDeps,
+} from "../infra/outbound/deliver.js";
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
import {
buildOutboundDeliveryJson,
@@ -15,13 +23,100 @@ import {
sendMessage,
sendPoll,
} from "../infra/outbound/message.js";
+import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js";
import type { RuntimeEnv } from "../runtime.js";
-import { normalizeMessageProvider } from "../utils/message-provider.js";
+
+type MessageAction =
+ | "send"
+ | "poll"
+ | "react"
+ | "reactions"
+ | "read"
+ | "edit"
+ | "delete"
+ | "pin"
+ | "unpin"
+ | "list-pins"
+ | "permissions"
+ | "thread-create"
+ | "thread-list"
+ | "thread-reply"
+ | "search"
+ | "sticker"
+ | "member-info"
+ | "role-info"
+ | "emoji-list"
+ | "emoji-upload"
+ | "sticker-upload"
+ | "role-add"
+ | "role-remove"
+ | "channel-info"
+ | "channel-list"
+ | "voice-status"
+ | "event-list"
+ | "event-create"
+ | "timeout"
+ | "kick"
+ | "ban";
+
+type MessageCommandOpts = {
+ action?: string;
+ provider?: string;
+ to?: string;
+ message?: string;
+ media?: string;
+ messageId?: string;
+ replyTo?: string;
+ threadId?: string;
+ account?: string;
+ emoji?: string;
+ remove?: boolean;
+ limit?: string;
+ before?: string;
+ after?: string;
+ around?: string;
+ pollQuestion?: string;
+ pollOption?: string[] | string;
+ pollDurationHours?: string;
+ pollMulti?: boolean;
+ channelId?: string;
+ channelIds?: string[] | string;
+ guildId?: string;
+ userId?: string;
+ authorId?: string;
+ authorIds?: string[] | string;
+ roleId?: string;
+ roleIds?: string[] | string;
+ emojiName?: string;
+ stickerId?: string[] | string;
+ stickerName?: string;
+ stickerDesc?: string;
+ stickerTags?: string;
+ threadName?: string;
+ autoArchiveMin?: string;
+ query?: string;
+ eventName?: string;
+ eventType?: string;
+ startTime?: string;
+ endTime?: string;
+ desc?: string;
+ location?: string;
+ durationMin?: string;
+ until?: string;
+ reason?: string;
+ deleteDays?: string;
+ includeArchived?: boolean;
+ participant?: string;
+ fromMe?: boolean;
+ dryRun?: boolean;
+ json?: boolean;
+ gifPlayback?: boolean;
+};
type MessageSendOpts = {
to: string;
message: string;
- provider?: string;
+ provider: string;
json?: boolean;
dryRun?: boolean;
media?: string;
@@ -29,19 +124,14 @@ type MessageSendOpts = {
account?: string;
};
-type MessagePollOpts = {
- to: string;
- question: string;
- option: string[];
- maxSelections?: string;
- durationHours?: string;
- provider?: string;
- json?: boolean;
- dryRun?: boolean;
-};
+function normalizeAction(value?: string): MessageAction {
+ const raw = value?.trim().toLowerCase() || "send";
+ return raw as MessageAction;
+}
function parseIntOption(value: unknown, label: string): number | undefined {
if (value === undefined || value === null) return undefined;
+ if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string" || value.trim().length === 0) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
@@ -50,13 +140,59 @@ function parseIntOption(value: unknown, label: string): number | undefined {
return parsed;
}
-function logSendDryRun(
- opts: MessageSendOpts,
- provider: string,
- runtime: RuntimeEnv,
-) {
+function requireString(value: unknown, label: string): string {
+ if (typeof value !== "string") {
+ throw new Error(`${label} required`);
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ throw new Error(`${label} required`);
+ }
+ return trimmed;
+}
+
+function optionalString(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+function toStringArray(value: unknown): string[] {
+ if (Array.isArray(value)) {
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ return trimmed ? [trimmed] : [];
+ }
+ return [];
+}
+
+function extractToolPayload(result: AgentToolResult): unknown {
+ if (result.details !== undefined) return result.details;
+ const textBlock = Array.isArray(result.content)
+ ? result.content.find(
+ (block) =>
+ block &&
+ typeof block === "object" &&
+ (block as { type?: unknown }).type === "text" &&
+ typeof (block as { text?: unknown }).text === "string",
+ )
+ : undefined;
+ const text = (textBlock as { text?: string } | undefined)?.text;
+ if (text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+ }
+ return result.content ?? result;
+}
+
+function logSendDryRun(opts: MessageSendOpts, runtime: RuntimeEnv) {
runtime.log(
- `[dry-run] would send via ${provider} -> ${opts.to}: ${opts.message}${
+ `[dry-run] would send via ${opts.provider} -> ${opts.to}: ${opts.message}${
opts.media ? ` (media ${opts.media})` : ""
}`,
);
@@ -128,125 +264,850 @@ function logSendResult(
}
}
-export async function messageSendCommand(
- opts: MessageSendOpts,
+export async function messageCommand(
+ opts: MessageCommandOpts,
deps: CliDeps,
runtime: RuntimeEnv,
) {
- const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
- if (opts.dryRun) {
- logSendDryRun(opts, provider, runtime);
+ const cfg = loadConfig();
+ const action = normalizeAction(opts.action);
+ const providerSelection = await resolveMessageProviderSelection({
+ cfg,
+ provider: opts.provider,
+ });
+ const provider = providerSelection.provider;
+ const outboundDeps: OutboundSendDeps = {
+ sendWhatsApp: deps.sendMessageWhatsApp,
+ sendTelegram: deps.sendMessageTelegram,
+ sendDiscord: deps.sendMessageDiscord,
+ sendSlack: deps.sendMessageSlack,
+ sendSignal: deps.sendMessageSignal,
+ sendIMessage: deps.sendMessageIMessage,
+ };
+
+ if (opts.dryRun && action !== "send" && action !== "poll") {
+ runtime.log(`[dry-run] would run ${action} via ${provider}`);
return;
}
- const result = await withProgress(
- {
- label: `Sending via ${provider}...`,
- indeterminate: true,
- enabled: opts.json !== true,
- },
- async () =>
- await sendMessage({
- cfg: loadConfig(),
- to: opts.to,
- content: opts.message,
- provider,
- mediaUrl: opts.media,
- gifPlayback: opts.gifPlayback,
- accountId: opts.account,
- dryRun: opts.dryRun,
- deps: deps
- ? {
- sendWhatsApp: deps.sendMessageWhatsApp,
- sendTelegram: deps.sendMessageTelegram,
- sendDiscord: deps.sendMessageDiscord,
- sendSlack: deps.sendMessageSlack,
- sendSignal: deps.sendMessageSignal,
- sendIMessage: deps.sendMessageIMessage,
- }
- : undefined,
- gateway: { clientName: "cli", mode: "cli" },
- }),
- );
-
- logSendResult(result, opts, runtime);
-}
-
-export async function messagePollCommand(
- opts: MessagePollOpts,
- _deps: CliDeps,
- runtime: RuntimeEnv,
-) {
- const provider = (opts.provider ?? "whatsapp").toLowerCase();
- const maxSelections = parseIntOption(opts.maxSelections, "max-selections");
- const durationHours = parseIntOption(opts.durationHours, "duration-hours");
-
- if (opts.dryRun) {
- const result = await sendPoll({
- cfg: loadConfig(),
- to: opts.to,
- question: opts.question,
- options: opts.option,
- maxSelections,
- durationHours,
+ if (action === "send") {
+ const to = requireString(opts.to, "to");
+ const message = requireString(opts.message, "message");
+ const sendOpts: MessageSendOpts = {
+ to,
+ message,
provider,
- dryRun: true,
- gateway: { clientName: "cli", mode: "cli" },
- });
- logPollDryRun(result, runtime);
+ json: opts.json,
+ dryRun: opts.dryRun,
+ media: optionalString(opts.media),
+ gifPlayback: opts.gifPlayback,
+ account: optionalString(opts.account),
+ };
+
+ if (opts.dryRun) {
+ logSendDryRun(sendOpts, runtime);
+ return;
+ }
+
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "sendMessage",
+ to,
+ content: message,
+ mediaUrl: optionalString(opts.media),
+ replyTo: optionalString(opts.replyTo),
+ },
+ cfg,
+ );
+ const payload = extractToolPayload(result);
+ if (opts.json) {
+ runtime.log(JSON.stringify(payload, null, 2));
+ } else {
+ runtime.log(success(`Sent via ${provider}.`));
+ }
+ return;
+ }
+
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "sendMessage",
+ to,
+ content: message,
+ mediaUrl: optionalString(opts.media),
+ threadTs:
+ optionalString(opts.threadId) ?? optionalString(opts.replyTo),
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ const payload = extractToolPayload(result);
+ if (opts.json) {
+ runtime.log(JSON.stringify(payload, null, 2));
+ } else {
+ runtime.log(success(`Sent via ${provider}.`));
+ }
+ return;
+ }
+
+ if (provider === "telegram") {
+ const result = await handleTelegramAction(
+ {
+ action: "sendMessage",
+ to,
+ content: message,
+ mediaUrl: optionalString(opts.media),
+ replyToMessageId: optionalString(opts.replyTo),
+ messageThreadId: optionalString(opts.threadId),
+ },
+ cfg,
+ );
+ const payload = extractToolPayload(result);
+ if (opts.json) {
+ runtime.log(JSON.stringify(payload, null, 2));
+ } else {
+ runtime.log(success(`Sent via ${provider}.`));
+ }
+ return;
+ }
+
+ const result = await withProgress(
+ {
+ label: `Sending via ${provider}...`,
+ indeterminate: true,
+ enabled: opts.json !== true,
+ },
+ async () =>
+ await sendMessage({
+ cfg,
+ to,
+ content: message,
+ provider,
+ mediaUrl: optionalString(opts.media),
+ gifPlayback: opts.gifPlayback,
+ accountId: optionalString(opts.account),
+ dryRun: opts.dryRun,
+ deps: outboundDeps,
+ gateway: { clientName: "cli", mode: "cli" },
+ }),
+ );
+ logSendResult(result, sendOpts, runtime);
return;
}
- const result = await withProgress(
- {
- label: `Sending poll via ${provider}...`,
- indeterminate: true,
- enabled: opts.json !== true,
- },
- async () =>
- await sendPoll({
- cfg: loadConfig(),
- to: opts.to,
- question: opts.question,
- options: opts.option,
+ if (action === "poll") {
+ const to = requireString(opts.to, "to");
+ const question = requireString(opts.pollQuestion, "poll-question");
+ const options = toStringArray(opts.pollOption);
+ if (options.length < 2) {
+ throw new Error("poll-option requires at least two values");
+ }
+ const durationHours = parseIntOption(
+ opts.pollDurationHours,
+ "poll-duration-hours",
+ );
+ const allowMultiselect = Boolean(opts.pollMulti);
+ const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
+
+ if (opts.dryRun) {
+ const result = await sendPoll({
+ cfg,
+ to,
+ question,
+ options,
maxSelections,
durationHours,
provider,
- dryRun: opts.dryRun,
+ dryRun: true,
gateway: { clientName: "cli", mode: "cli" },
- }),
- );
+ });
+ logPollDryRun(result, runtime);
+ return;
+ }
- runtime.log(
- success(
- formatGatewaySummary({
- action: "Poll sent",
- provider,
- messageId: result.result?.messageId ?? null,
- }),
- ),
- );
- if (opts.json) {
- runtime.log(
- JSON.stringify(
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
{
- ...buildOutboundResultEnvelope({
- delivery: buildOutboundDeliveryJson({
- provider,
- via: "gateway",
- to: opts.to,
- result: result.result,
- mediaUrl: null,
- }),
- }),
- question: result.question,
- options: result.options,
- maxSelections: result.maxSelections,
- durationHours: result.durationHours,
+ action: "poll",
+ to,
+ question,
+ answers: options,
+ allowMultiselect,
+ durationHours: durationHours ?? undefined,
+ content: optionalString(opts.message),
},
- null,
- 2,
+ cfg,
+ );
+ const payload = extractToolPayload(result);
+ if (opts.json) {
+ runtime.log(JSON.stringify(payload, null, 2));
+ } else {
+ runtime.log(success(`Poll sent via ${provider}.`));
+ }
+ return;
+ }
+
+ const result = await withProgress(
+ {
+ label: `Sending poll via ${provider}...`,
+ indeterminate: true,
+ enabled: opts.json !== true,
+ },
+ async () =>
+ await sendPoll({
+ cfg,
+ to,
+ question,
+ options,
+ maxSelections,
+ durationHours,
+ provider,
+ dryRun: opts.dryRun,
+ gateway: { clientName: "cli", mode: "cli" },
+ }),
+ );
+
+ runtime.log(
+ success(
+ formatGatewaySummary({
+ action: "Poll sent",
+ provider,
+ messageId: result.result?.messageId ?? null,
+ }),
),
);
+ if (opts.json) {
+ runtime.log(
+ JSON.stringify(
+ {
+ ...buildOutboundResultEnvelope({
+ delivery: buildOutboundDeliveryJson({
+ provider,
+ via: "gateway",
+ to,
+ result: result.result,
+ mediaUrl: null,
+ }),
+ }),
+ question: result.question,
+ options: result.options,
+ maxSelections: result.maxSelections,
+ durationHours: result.durationHours,
+ },
+ null,
+ 2,
+ ),
+ );
+ }
+ return;
}
+
+ if (action === "react") {
+ const messageId = requireString(opts.messageId, "message-id");
+ const emoji = optionalString(opts.emoji) ?? "";
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "react",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ emoji,
+ remove: opts.remove,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "react",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ emoji,
+ remove: opts.remove,
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "telegram") {
+ const result = await handleTelegramAction(
+ {
+ action: "react",
+ chatId: requireString(opts.to, "to"),
+ messageId,
+ emoji,
+ remove: opts.remove,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "whatsapp") {
+ const result = await handleWhatsAppAction(
+ {
+ action: "react",
+ chatJid: requireString(opts.to, "to"),
+ messageId,
+ emoji,
+ remove: opts.remove,
+ participant: optionalString(opts.participant),
+ accountId: optionalString(opts.account),
+ fromMe: opts.fromMe,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`React is not supported for provider ${provider}.`);
+ }
+
+ if (action === "reactions") {
+ const messageId = requireString(opts.messageId, "message-id");
+ const limit = parseIntOption(opts.limit, "limit");
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "reactions",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ limit,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "reactions",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ limit,
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Reactions are not supported for provider ${provider}.`);
+ }
+
+ if (action === "read") {
+ const limit = parseIntOption(opts.limit, "limit");
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "readMessages",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ limit,
+ before: optionalString(opts.before),
+ after: optionalString(opts.after),
+ around: optionalString(opts.around),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "readMessages",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ limit,
+ before: optionalString(opts.before),
+ after: optionalString(opts.after),
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Read is not supported for provider ${provider}.`);
+ }
+
+ if (action === "edit") {
+ const messageId = requireString(opts.messageId, "message-id");
+ const message = requireString(opts.message, "message");
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "editMessage",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ content: message,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "editMessage",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ content: message,
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Edit is not supported for provider ${provider}.`);
+ }
+
+ if (action === "delete") {
+ const messageId = requireString(opts.messageId, "message-id");
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "deleteMessage",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "deleteMessage",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ messageId,
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Delete is not supported for provider ${provider}.`);
+ }
+
+ if (action === "pin" || action === "unpin" || action === "list-pins") {
+ const channelId = requireString(opts.channelId ?? opts.to, "to");
+ const messageId =
+ action === "list-pins"
+ ? undefined
+ : requireString(opts.messageId, "message-id");
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action:
+ action === "pin"
+ ? "pinMessage"
+ : action === "unpin"
+ ? "unpinMessage"
+ : "listPins",
+ channelId,
+ messageId,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action:
+ action === "pin"
+ ? "pinMessage"
+ : action === "unpin"
+ ? "unpinMessage"
+ : "listPins",
+ channelId,
+ messageId,
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Pins are not supported for provider ${provider}.`);
+ }
+
+ if (action === "permissions") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Permissions are only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "permissions",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "thread-create") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Thread create is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "threadCreate",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ name: requireString(opts.threadName, "thread-name"),
+ messageId: optionalString(opts.messageId),
+ autoArchiveMinutes: parseIntOption(
+ opts.autoArchiveMin,
+ "auto-archive-min",
+ ),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "thread-list") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Thread list is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "threadList",
+ guildId: requireString(opts.guildId, "guild-id"),
+ channelId: optionalString(opts.channelId),
+ includeArchived: opts.includeArchived,
+ before: optionalString(opts.before),
+ limit: parseIntOption(opts.limit, "limit"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "thread-reply") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Thread reply is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "threadReply",
+ channelId: requireString(opts.channelId ?? opts.to, "to"),
+ content: requireString(opts.message, "message"),
+ mediaUrl: optionalString(opts.media),
+ replyTo: optionalString(opts.replyTo),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "search") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Search is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "searchMessages",
+ guildId: requireString(opts.guildId, "guild-id"),
+ content: requireString(opts.query, "query"),
+ channelId: optionalString(opts.channelId),
+ channelIds: toStringArray(opts.channelIds),
+ authorId: optionalString(opts.authorId),
+ authorIds: toStringArray(opts.authorIds),
+ limit: parseIntOption(opts.limit, "limit"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "sticker") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Sticker send is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const stickerIds = toStringArray(opts.stickerId);
+ if (stickerIds.length === 0) {
+ throw new Error("sticker-id required");
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "sticker",
+ to: requireString(opts.to, "to"),
+ stickerIds,
+ content: optionalString(opts.message),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "member-info") {
+ const userId = requireString(opts.userId, "user-id");
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "memberInfo",
+ guildId: requireString(opts.guildId, "guild-id"),
+ userId,
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ {
+ action: "memberInfo",
+ userId,
+ accountId: optionalString(opts.account),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Member info is not supported for provider ${provider}.`);
+ }
+
+ if (action === "role-info") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Role info is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ { action: "roleInfo", guildId: requireString(opts.guildId, "guild-id") },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "emoji-list") {
+ if (provider === "discord") {
+ const result = await handleDiscordAction(
+ {
+ action: "emojiList",
+ guildId: requireString(opts.guildId, "guild-id"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ if (provider === "slack") {
+ const result = await handleSlackAction(
+ { action: "emojiList", accountId: optionalString(opts.account) },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+ throw new Error(`Emoji list is not supported for provider ${provider}.`);
+ }
+
+ if (action === "emoji-upload") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Emoji upload is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "emojiUpload",
+ guildId: requireString(opts.guildId, "guild-id"),
+ name: requireString(opts.emojiName, "emoji-name"),
+ mediaUrl: requireString(opts.media, "media"),
+ roleIds: toStringArray(opts.roleIds),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "sticker-upload") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Sticker upload is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "stickerUpload",
+ guildId: requireString(opts.guildId, "guild-id"),
+ name: requireString(opts.stickerName, "sticker-name"),
+ description: requireString(opts.stickerDesc, "sticker-desc"),
+ tags: requireString(opts.stickerTags, "sticker-tags"),
+ mediaUrl: requireString(opts.media, "media"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "role-add" || action === "role-remove") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Role changes are only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: action === "role-add" ? "roleAdd" : "roleRemove",
+ guildId: requireString(opts.guildId, "guild-id"),
+ userId: requireString(opts.userId, "user-id"),
+ roleId: requireString(opts.roleId, "role-id"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "channel-info") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Channel info is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "channelInfo",
+ channelId: requireString(opts.channelId, "channel-id"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "channel-list") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Channel list is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "channelList",
+ guildId: requireString(opts.guildId, "guild-id"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "voice-status") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Voice status is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "voiceStatus",
+ guildId: requireString(opts.guildId, "guild-id"),
+ userId: requireString(opts.userId, "user-id"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "event-list") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Event list is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ { action: "eventList", guildId: requireString(opts.guildId, "guild-id") },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "event-create") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Event create is only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: "eventCreate",
+ guildId: requireString(opts.guildId, "guild-id"),
+ name: requireString(opts.eventName, "event-name"),
+ startTime: requireString(opts.startTime, "start-time"),
+ endTime: optionalString(opts.endTime),
+ description: optionalString(opts.desc),
+ channelId: optionalString(opts.channelId),
+ location: optionalString(opts.location),
+ entityType: optionalString(opts.eventType),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ if (action === "timeout" || action === "kick" || action === "ban") {
+ if (provider !== "discord") {
+ throw new Error(
+ `Moderation actions are only supported for Discord (provider=${provider}).`,
+ );
+ }
+ const result = await handleDiscordAction(
+ {
+ action: action as "timeout" | "kick" | "ban",
+ guildId: requireString(opts.guildId, "guild-id"),
+ userId: requireString(opts.userId, "user-id"),
+ durationMinutes: parseIntOption(opts.durationMin, "duration-min"),
+ until: optionalString(opts.until),
+ reason: optionalString(opts.reason),
+ deleteMessageDays: parseIntOption(opts.deleteDays, "delete-days"),
+ },
+ cfg,
+ );
+ runtime.log(JSON.stringify(extractToolPayload(result), null, 2));
+ return;
+ }
+
+ throw new Error(`Unknown action: ${opts.action ?? "unknown"}`);
}
diff --git a/src/infra/outbound/provider-selection.ts b/src/infra/outbound/provider-selection.ts
new file mode 100644
index 000000000..b969c0585
--- /dev/null
+++ b/src/infra/outbound/provider-selection.ts
@@ -0,0 +1,113 @@
+import type { ClawdbotConfig } from "../../config/config.js";
+import { listEnabledDiscordAccounts } from "../../discord/accounts.js";
+import { listEnabledIMessageAccounts } from "../../imessage/accounts.js";
+import { listEnabledSignalAccounts } from "../../signal/accounts.js";
+import { listEnabledSlackAccounts } from "../../slack/accounts.js";
+import { listEnabledTelegramAccounts } from "../../telegram/accounts.js";
+import { normalizeMessageProvider } from "../../utils/message-provider.js";
+import {
+ listEnabledWhatsAppAccounts,
+ resolveWhatsAppAccount,
+} from "../../web/accounts.js";
+import { webAuthExists } from "../../web/session.js";
+
+export type MessageProviderId =
+ | "whatsapp"
+ | "telegram"
+ | "discord"
+ | "slack"
+ | "signal"
+ | "imessage";
+
+const MESSAGE_PROVIDERS: MessageProviderId[] = [
+ "whatsapp",
+ "telegram",
+ "discord",
+ "slack",
+ "signal",
+ "imessage",
+];
+
+function isKnownProvider(value: string): value is MessageProviderId {
+ return (MESSAGE_PROVIDERS as string[]).includes(value);
+}
+
+async function isWhatsAppConfigured(cfg: ClawdbotConfig): Promise {
+ const accounts = listEnabledWhatsAppAccounts(cfg);
+ if (accounts.length === 0) {
+ const fallback = resolveWhatsAppAccount({ cfg });
+ return await webAuthExists(fallback.authDir);
+ }
+ for (const account of accounts) {
+ if (await webAuthExists(account.authDir)) return true;
+ }
+ return false;
+}
+
+function isTelegramConfigured(cfg: ClawdbotConfig): boolean {
+ return listEnabledTelegramAccounts(cfg).some(
+ (account) => account.token.trim().length > 0,
+ );
+}
+
+function isDiscordConfigured(cfg: ClawdbotConfig): boolean {
+ return listEnabledDiscordAccounts(cfg).some(
+ (account) => account.token.trim().length > 0,
+ );
+}
+
+function isSlackConfigured(cfg: ClawdbotConfig): boolean {
+ return listEnabledSlackAccounts(cfg).some(
+ (account) => (account.botToken ?? "").trim().length > 0,
+ );
+}
+
+function isSignalConfigured(cfg: ClawdbotConfig): boolean {
+ return listEnabledSignalAccounts(cfg).some((account) => account.configured);
+}
+
+function isIMessageConfigured(cfg: ClawdbotConfig): boolean {
+ return listEnabledIMessageAccounts(cfg).some((account) => account.configured);
+}
+
+export async function listConfiguredMessageProviders(
+ cfg: ClawdbotConfig,
+): Promise {
+ const providers: MessageProviderId[] = [];
+ if (await isWhatsAppConfigured(cfg)) providers.push("whatsapp");
+ if (isTelegramConfigured(cfg)) providers.push("telegram");
+ if (isDiscordConfigured(cfg)) providers.push("discord");
+ if (isSlackConfigured(cfg)) providers.push("slack");
+ if (isSignalConfigured(cfg)) providers.push("signal");
+ if (isIMessageConfigured(cfg)) providers.push("imessage");
+ return providers;
+}
+
+export async function resolveMessageProviderSelection(params: {
+ cfg: ClawdbotConfig;
+ provider?: string | null;
+}): Promise<{ provider: MessageProviderId; configured: MessageProviderId[] }> {
+ const normalized = normalizeMessageProvider(params.provider);
+ if (normalized) {
+ if (!isKnownProvider(normalized)) {
+ throw new Error(`Unknown provider: ${normalized}`);
+ }
+ return {
+ provider: normalized,
+ configured: await listConfiguredMessageProviders(params.cfg),
+ };
+ }
+
+ const configured = await listConfiguredMessageProviders(params.cfg);
+ if (configured.length === 1) {
+ return { provider: configured[0], configured };
+ }
+ if (configured.length === 0) {
+ throw new Error("Provider is required (no configured providers detected).");
+ }
+ throw new Error(
+ `Provider is required when multiple providers are configured: ${configured.join(
+ ", ",
+ )}`,
+ );
+}
diff --git a/src/slack/actions.ts b/src/slack/actions.ts
index 9df6d32d2..c108ebe72 100644
--- a/src/slack/actions.ts
+++ b/src/slack/actions.ts
@@ -145,13 +145,14 @@ export async function listSlackReactions(
export async function sendSlackMessage(
to: string,
content: string,
- opts: SlackActionClientOpts & { mediaUrl?: string } = {},
+ opts: SlackActionClientOpts & { mediaUrl?: string; threadTs?: string } = {},
) {
return await sendMessageSlack(to, content, {
accountId: opts.accountId,
token: opts.token,
mediaUrl: opts.mediaUrl,
client: opts.client,
+ threadTs: opts.threadTs,
});
}
From 732972be2b823f3e30c380e51e23e6db9b22c86b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 08:32:15 +0100
Subject: [PATCH 027/220] style: format control ui assets log
---
src/infra/control-ui-assets.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts
index 9c46d5a9d..61c343caf 100644
--- a/src/infra/control-ui-assets.ts
+++ b/src/infra/control-ui-assets.ts
@@ -94,7 +94,9 @@ export async function ensureControlUiAssetsBuilt(
};
}
- runtime.log("Control UI assets missing; building (ui:build, auto-installs UI deps)…");
+ runtime.log(
+ "Control UI assets missing; building (ui:build, auto-installs UI deps)…",
+ );
const build = await runCommandWithTimeout(
[process.execPath, uiScript, "build"],
From 0a31112fb49d525c6ce097e9a8ddadb06714e7c9 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 08:46:50 +0100
Subject: [PATCH 028/220] docs(changelog): mention gateway discover
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4a9034ce..e7ed87ec1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -77,6 +77,7 @@
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
+- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
## 2026.1.8
From 7ed53e243d8434ae4a265c9abc74223094711f95 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 08:51:22 +0100
Subject: [PATCH 029/220] fix(cli): clear lint warnings
---
src/cli/banner.ts | 16 +++++++++++++++-
src/cli/gateway-cli.ts | 10 +++++++++-
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/src/cli/banner.ts b/src/cli/banner.ts
index 670a1cddc..05541f1b5 100644
--- a/src/cli/banner.ts
+++ b/src/cli/banner.ts
@@ -10,6 +10,20 @@ type BannerOptions = TaglineOptions & {
let bannerEmitted = false;
+const graphemeSegmenter =
+ typeof Intl !== "undefined" && "Segmenter" in Intl
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
+ : null;
+
+function splitGraphemes(value: string): string[] {
+ if (!graphemeSegmenter) return Array.from(value);
+ try {
+ return Array.from(graphemeSegmenter.segment(value), (seg) => seg.segment);
+ } catch {
+ return Array.from(value);
+ }
+}
+
const hasJsonFlag = (argv: string[]) =>
argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
@@ -62,7 +76,7 @@ export function formatCliBannerArt(options: BannerOptions = {}): string {
theme.accent("🦞")
);
}
- return [...line].map(colorChar).join("");
+ return splitGraphemes(line).map(colorChar).join("");
});
return colored.join("\n");
diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts
index ed853cb08..c040b8feb 100644
--- a/src/cli/gateway-cli.ts
+++ b/src/cli/gateway-cli.ts
@@ -98,7 +98,15 @@ type GatewayDiscoverOpts = {
function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number {
if (raw === undefined || raw === null) return fallbackMs;
- const value = typeof raw === "string" ? raw.trim() : String(raw);
+ const value =
+ typeof raw === "string"
+ ? raw.trim()
+ : typeof raw === "number" || typeof raw === "bigint"
+ ? String(raw)
+ : null;
+ if (value === null) {
+ throw new Error("invalid --timeout");
+ }
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
From f9be9ad426c88086cfba5985db6a33f19693236b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Fri, 9 Jan 2026 08:59:54 +0100
Subject: [PATCH 030/220] feat: switch message cli to subcommands
---
AGENTS.md | 8 +-
CHANGELOG.md | 2 +-
README.md | 2 +-
docs/automation/poll.md | 8 +-
docs/cli/message.md | 205 +++++-------
docs/gateway/index.md | 2 +-
docs/index.md | 2 +-
docs/nodes/images.md | 6 +-
docs/providers/telegram.md | 2 +-
docs/providers/whatsapp.md | 2 +-
docs/start/faq.md | 2 +-
docs/start/getting-started.md | 2 +-
src/cli/program.test.ts | 9 +-
src/cli/program.ts | 572 +++++++++++++++++++++++++++-------
14 files changed, 561 insertions(+), 263 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index f120b43ee..42088930c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,17 +93,17 @@
- Voice wake forwarding tips:
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
-- For manual `clawdbot message --action send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
+- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
## Exclamation Mark Escaping Workaround
-The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message --action send` with messages containing exclamation marks, use heredoc syntax:
+The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
```bash
# WRONG - will send "Hello\\!" with backslash
-clawdbot message --action send --to "+1234" --message 'Hello!'
+clawdbot message send --to "+1234" --message 'Hello!'
# CORRECT - use heredoc to avoid escaping
-clawdbot message --action send --to "+1234" --message "$(cat <<'EOF'
+clawdbot message send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7ed87ec1..70f9aeca8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
- Commands: accept /models as an alias for /model.
- Debugging: add raw model stream logging flags and document gateway watch mode.
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
-- CLI: replace `message send`/`message poll` with `message --action ...`, and fold Discord/Slack/Telegram/WhatsApp tools into `message` (provider required unless only one configured).
+- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
diff --git a/README.md b/README.md
index 84cd78435..18ea6ca16 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ clawdbot onboard --install-daemon
clawdbot gateway --port 18789 --verbose
# Send a message
-clawdbot message --to +1234567890 --message "Hello from Clawdbot"
+clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
clawdbot agent --message "Ship checklist" --thinking high
diff --git a/docs/automation/poll.md b/docs/automation/poll.md
index 071b4a071..39307f946 100644
--- a/docs/automation/poll.md
+++ b/docs/automation/poll.md
@@ -15,15 +15,15 @@ read_when:
```bash
# WhatsApp
-clawdbot message --action poll --to +15555550123 \
+clawdbot message poll --to +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
-clawdbot message --action poll --to 123456789@g.us \
+clawdbot message poll --to 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord
-clawdbot message --action poll --provider discord --to channel:123456789 \
+clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
-clawdbot message --action poll --provider discord --to channel:123456789 \
+clawdbot message poll --provider discord --to channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
```
diff --git a/docs/cli/message.md b/docs/cli/message.md
index 7ddc33fda..47aa67320 100644
--- a/docs/cli/message.md
+++ b/docs/cli/message.md
@@ -13,12 +13,9 @@ Single outbound command for sending messages and provider actions
## Usage
```
-clawdbot message --action [--provider ] [flags]
+clawdbot message [flags]
```
-Defaults:
-- `--action send`
-
Provider selection:
- `--provider` required if more than one provider is configured.
- If exactly one provider is configured, it becomes the default.
@@ -33,170 +30,124 @@ Target formats (`--to`):
## Common flags
-- `--to `
-- `--message `
-- `--media `
-- `--message-id `
-- `--reply-to `
-- `--thread-id ` (Telegram forum thread)
-- `--account ` (multi-account providers)
-- `--dry-run`
+- `--provider `
+- `--account `
- `--json`
+- `--dry-run`
- `--verbose`
## Actions
-### `send`
-Providers: whatsapp, telegram, discord, slack, signal, imessage
-Required: `--to`, `--message`
-Optional: `--media`, `--reply-to`, `--thread-id`, `--account`, `--gif-playback`
+### Core
-### `react`
-Providers: discord, slack, telegram, whatsapp
-Required: `--to`, `--message-id`
-Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--account`
+- `send`
+ - Required: `--to`, `--message`
+ - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
-### `reactions`
-Providers: discord, slack
-Required: `--to`, `--message-id`
-Optional: `--limit`
+- `poll`
+ - Required: `--to`, `--poll-question`, `--poll-option` (repeat)
+ - Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
-### `read`
-Providers: discord, slack
-Required: `--to`
-Optional: `--limit`, `--before`, `--after`, `--around`
+- `react`
+ - Required: `--to`, `--message-id`
+ - Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
-### `edit`
-Providers: discord, slack
-Required: `--to`, `--message-id`, `--message`
+- `reactions`
+ - Required: `--to`, `--message-id`
+ - Optional: `--limit`, `--channel-id`
-### `delete`
-Providers: discord, slack
-Required: `--to`, `--message-id`
+- `read`
+ - Required: `--to`
+ - Optional: `--limit`, `--before`, `--after`, `--around`, `--channel-id`
-### `pin`
-Providers: discord, slack
-Required: `--to`, `--message-id`
+- `edit`
+ - Required: `--to`, `--message-id`, `--message`
+ - Optional: `--channel-id`
-### `unpin`
-Providers: discord, slack
-Required: `--to`, `--message-id`
+- `delete`
+ - Required: `--to`, `--message-id`
+ - Optional: `--channel-id`
-### `list-pins`
-Providers: discord, slack
-Required: `--to`
+- `pin` / `unpin`
+ - Required: `--to`, `--message-id`
+ - Optional: `--channel-id`
-### `poll`
-Providers: whatsapp, discord
-Required: `--to`, `--poll-question`, `--poll-option` (repeat)
-Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
+- `pins` (list)
+ - Required: `--to`
+ - Optional: `--channel-id`
-### `sticker`
-Providers: discord
-Required: `--to`, `--sticker-id` (repeat)
-Optional: `--message`
+- `permissions`
+ - Required: `--to`
+ - Optional: `--channel-id`
-### `permissions`
-Providers: discord
-Required: `--to` (channel id)
+- `search`
+ - Required: `--guild-id`, `--query`
+ - Optional: `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`
-### `thread-create`
-Providers: discord
-Required: `--to` (channel id), `--thread-name`
-Optional: `--message-id`, `--auto-archive-min`
+### Threads
-### `thread-list`
-Providers: discord
-Required: `--guild-id`
-Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
+- `thread create`
+ - Required: `--thread-name`, `--to` (channel id) or `--channel-id`
+ - Optional: `--message-id`, `--auto-archive-min`
-### `thread-reply`
-Providers: discord
-Required: `--to` (thread id), `--message`
-Optional: `--media`, `--reply-to`
+- `thread list`
+ - Required: `--guild-id`
+ - Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
-### `search`
-Providers: discord
-Required: `--guild-id`, `--query`
-Optional: `--channel-id`, `--channel-ids`, `--author-id`, `--author-ids`, `--limit`
+- `thread reply`
+ - Required: `--to` (thread id), `--message`
+ - Optional: `--media`, `--reply-to`
-### `member-info`
-Providers: discord, slack
-Required: `--user-id`
-Discord only: also `--guild-id`
+### Emojis
-### `role-info`
-Providers: discord
-Required: `--guild-id`
+- `emoji list`
+ - Discord: `--guild-id`
-### `emoji-list`
-Providers: discord, slack
-Discord only: `--guild-id`
+- `emoji upload`
+ - Required: `--guild-id`, `--emoji-name`, `--media`
+ - Optional: `--role-ids` (repeat)
-### `emoji-upload`
-Providers: discord
-Required: `--guild-id`, `--emoji-name`, `--media`
-Optional: `--role-ids` (repeat)
+### Stickers
-### `sticker-upload`
-Providers: discord
-Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
+- `sticker send`
+ - Required: `--to`, `--sticker-id` (repeat)
+ - Optional: `--message`
-### `role-add`
-Providers: discord
-Required: `--guild-id`, `--user-id`, `--role-id`
+- `sticker upload`
+ - Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
-### `role-remove`
-Providers: discord
-Required: `--guild-id`, `--user-id`, `--role-id`
+### Roles / Channels / Members / Voice
-### `channel-info`
-Providers: discord
-Required: `--channel-id`
+- `role info` (Discord): `--guild-id`
+- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
+- `channel info` (Discord): `--channel-id`
+- `channel list` (Discord): `--guild-id`
+- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
+- `voice status` (Discord): `--guild-id`, `--user-id`
-### `channel-list`
-Providers: discord
-Required: `--guild-id`
+### Events
-### `voice-status`
-Providers: discord
-Required: `--guild-id`, `--user-id`
+- `event list` (Discord): `--guild-id`
+- `event create` (Discord): `--guild-id`, `--event-name`, `--start-time`
+ - Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type`
-### `event-list`
-Providers: discord
-Required: `--guild-id`
+### Moderation (Discord)
-### `event-create`
-Providers: discord
-Required: `--guild-id`, `--event-name`, `--start-time`
-Optional: `--end-time`, `--desc`, `--channel-id`, `--location`, `--event-type`
-
-### `timeout`
-Providers: discord
-Required: `--guild-id`, `--user-id`
-Optional: `--duration-min`, `--until`, `--reason`
-
-### `kick`
-Providers: discord
-Required: `--guild-id`, `--user-id`
-Optional: `--reason`
-
-### `ban`
-Providers: discord
-Required: `--guild-id`, `--user-id`
-Optional: `--reason`, `--delete-days`
+- `timeout`: `--guild-id`, `--user-id` (+ `--duration-min` or `--until`)
+- `kick`: `--guild-id`, `--user-id`
+- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`)
## Examples
Send a Discord reply:
```
-clawdbot message --action send --provider discord \
+clawdbot message send --provider discord \
--to channel:123 --message "hi" --reply-to 456
```
Create a Discord poll:
```
-clawdbot message --action poll --provider discord \
+clawdbot message poll --provider discord \
--to channel:123 \
--poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \
@@ -205,6 +156,6 @@ clawdbot message --action poll --provider discord \
React in Slack:
```
-clawdbot message --action react --provider slack \
+clawdbot message react --provider slack \
--to C123 --message-id 456 --emoji "✅"
```
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index 712ed6246..9b2e3dcf2 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -254,7 +254,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS.
-- `clawdbot message --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
+- `clawdbot message send --to --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to ` — run an agent turn (waits for final by default).
- `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
diff --git a/docs/index.md b/docs/index.md
index e01f53e61..35332ecf1 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -134,7 +134,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway):
```bash
-clawdbot message --to +15555550123 --message "Hello from CLAWDBOT"
+clawdbot message send --to +15555550123 --message "Hello from CLAWDBOT"
```
## Configuration (optional)
diff --git a/docs/nodes/images.md b/docs/nodes/images.md
index 8235cc992..84c1a3008 100644
--- a/docs/nodes/images.md
+++ b/docs/nodes/images.md
@@ -8,12 +8,12 @@ read_when:
CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies.
## Goals
-- Send media with optional captions via `clawdbot message --media`.
+- Send media with optional captions via `clawdbot message send --media`.
- Allow auto-replies from the web inbox to include media alongside text.
- Keep per-type limits sane and predictable.
## CLI Surface
-- `clawdbot message --media [--message ]`
+- `clawdbot message send --media [--message ]`
- `--media` optional; caption can be empty for media-only sends.
- `--dry-run` prints the resolved payload; `--json` emits `{ provider, to, messageId, mediaUrl, caption }`.
@@ -30,7 +30,7 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
## Auto-Reply Pipeline
- `getReplyFromConfig` returns `{ text?, mediaUrl?, mediaUrls? }`.
-- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message`.
+- When media is present, the web sender resolves local paths or URLs using the same pipeline as `clawdbot message send`.
- Multiple media entries are sent sequentially if provided.
## Inbound Media to Commands (Pi)
diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md
index b9f14f1ad..42cf31cf2 100644
--- a/docs/providers/telegram.md
+++ b/docs/providers/telegram.md
@@ -223,7 +223,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
-- Example: `clawdbot message --provider telegram --to 123456789 --message "hi"`.
+- Example: `clawdbot message send --provider telegram --to 123456789 --message "hi"`.
## Troubleshooting
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index ecb9ee190..4cec0dc62 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -158,7 +158,7 @@ Behavior:
- Caption only on first media item.
- Media fetch supports HTTP(S) and local paths.
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
- - CLI: `clawdbot message --media --gif-playback`
+ - CLI: `clawdbot message send --media --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
## Media limits + optimization
diff --git a/docs/start/faq.md b/docs/start/faq.md
index aecd25afb..06be764b1 100644
--- a/docs/start/faq.md
+++ b/docs/start/faq.md
@@ -560,7 +560,7 @@ Outbound attachments from the agent must include a `MEDIA:` line (o
CLI sending:
```bash
-clawdbot message --to +15555550123 --message "Here you go" --media /path/to/file.png
+clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
```
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md
index 8bc36d54f..f81d70a20 100644
--- a/docs/start/getting-started.md
+++ b/docs/start/getting-started.md
@@ -152,7 +152,7 @@ In a new terminal:
```bash
clawdbot health
-clawdbot message --to +15555550123 --message "Hello from Clawdbot"
+clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
```
If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts
index 3d44f0fbd..20bbf62cc 100644
--- a/src/cli/program.test.ts
+++ b/src/cli/program.test.ts
@@ -47,9 +47,12 @@ describe("cli program", () => {
it("runs message with required options", async () => {
const program = buildProgram();
- await program.parseAsync(["message", "--to", "+1", "--message", "hi"], {
- from: "user",
- });
+ await program.parseAsync(
+ ["message", "send", "--to", "+1", "--message", "hi"],
+ {
+ from: "user",
+ },
+ );
expect(messageCommand).toHaveBeenCalled();
});
diff --git a/src/cli/program.ts b/src/cli/program.ts
index 1033ef145..23eac4ccb 100644
--- a/src/cli/program.ts
+++ b/src/cli/program.ts
@@ -408,128 +408,472 @@ export function buildProgram() {
}
});
- program
+ const message = program
.command("message")
.description("Send messages and provider actions")
- .option(
- "-a, --action ",
- "Action: send|poll|react|reactions|read|edit|delete|pin|unpin|list-pins|permissions|thread-create|thread-list|thread-reply|search|sticker|member-info|role-info|emoji-list|emoji-upload|sticker-upload|role-add|role-remove|channel-info|channel-list|voice-status|event-list|event-create|timeout|kick|ban",
- "send",
- )
- .option(
- "-t, --to ",
- "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id",
- )
- .option("-m, --message ", "Message body")
- .option(
- "--media ",
- "Attach media (image/audio/video/document). Accepts local paths or URLs.",
- )
- .option("--message-id ", "Message id (edit/delete/react/pin)")
- .option("--reply-to ", "Reply-to message id")
- .option("--thread-id ", "Thread id (Telegram forum thread)")
- .option("--account ", "Provider account id")
- .option(
- "--provider ",
- "Provider: whatsapp|telegram|discord|slack|signal|imessage",
- )
- .option("--emoji ", "Emoji for reactions")
- .option("--remove", "Remove reaction", false)
- .option("--limit ", "Result limit for read/reactions/search")
- .option("--before ", "Read/search before id")
- .option("--after ", "Read/search after id")
- .option("--around ", "Read around id (Discord)")
- .option("--poll-question ", "Poll question")
- .option(
- "--poll-option ",
- "Poll option (repeat 2-12 times)",
- (value: string, previous: string[]) => previous.concat([value]),
- [] as string[],
- )
- .option("--poll-multi", "Allow multiple selections", false)
- .option("--poll-duration-hours ", "Poll duration (Discord)")
- .option("--channel-id ", "Channel id")
- .option(
- "--channel-ids ",
- "Channel id (repeat)",
- (value: string, previous: string[]) => previous.concat([value]),
- [] as string[],
- )
- .option("--guild-id ", "Guild id")
- .option("--user-id ", "User id")
- .option("--author-id ", "Author id")
- .option(
- "--author-ids ",
- "Author id (repeat)",
- (value: string, previous: string[]) => previous.concat([value]),
- [] as string[],
- )
- .option("--role-id ", "Role id")
- .option(
- "--role-ids ",
- "Role id (repeat)",
- (value: string, previous: string[]) => previous.concat([value]),
- [] as string[],
- )
- .option("--emoji-name ", "Emoji name")
- .option(
- "--sticker-id ",
- "Sticker id (repeat)",
- (value: string, previous: string[]) => previous.concat([value]),
- [] as string[],
- )
- .option("--sticker-name ", "Sticker name")
- .option("--sticker-desc ", "Sticker description")
- .option("--sticker-tags ", "Sticker tags")
- .option("--thread-name ", "Thread name")
- .option("--auto-archive-min ", "Thread auto-archive minutes")
- .option("--query ", "Search query")
- .option("--event-name ", "Event name")
- .option("--event-type ", "Event type")
- .option("--start-time ", "Event start time")
- .option("--end-time ", "Event end time")
- .option("--desc ", "Event description")
- .option("--location ", "Event location")
- .option("--duration-min ", "Timeout duration minutes")
- .option("--until ", "Timeout until")
- .option("--reason ", "Moderation reason")
- .option("--delete-days ", "Ban delete message days")
- .option("--include-archived", "Include archived threads", false)
- .option("--participant ", "WhatsApp reaction participant")
- .option("--from-me", "WhatsApp reaction fromMe", false)
- .option(
- "--gif-playback",
- "Treat video media as GIF playback (WhatsApp only).",
- false,
- )
- .option("--dry-run", "Print payload and skip sending", false)
- .option("--json", "Output result as JSON", false)
- .option("--verbose", "Verbose logging", false)
.addHelpText(
"after",
`
Examples:
- clawdbot message --to +15555550123 --message "Hi"
- clawdbot message --action send --to +15555550123 --message "Hi" --media photo.jpg
- clawdbot message --action poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
- clawdbot message --action react --provider discord --to 123 --message-id 456 --emoji "✅"`,
+ clawdbot message send --to +15555550123 --message "Hi"
+ clawdbot message send --to +15555550123 --message "Hi" --media photo.jpg
+ clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
+ clawdbot message react --provider discord --to 123 --message-id 456 --emoji "✅"`,
+ )
+ .action(() => {
+ message.help({ error: true });
+ });
+
+ const withMessageBase = (command: Command) =>
+ command
+ .option(
+ "--provider ",
+ "Provider: whatsapp|telegram|discord|slack|signal|imessage",
+ )
+ .option("--account ", "Provider account id")
+ .option("--json", "Output result as JSON", false)
+ .option("--dry-run", "Print payload and skip sending", false)
+ .option("--verbose", "Verbose logging", false);
+
+ const withMessageTarget = (command: Command) =>
+ command.option(
+ "-t, --to