diff --git a/docs/cli/channels.md b/docs/cli/channels.md
index 26657e0c5..0cbe2a6f2 100644
--- a/docs/cli/channels.md
+++ b/docs/cli/channels.md
@@ -40,6 +40,27 @@ openclaw channels login --channel whatsapp
openclaw channels logout --channel whatsapp
```
+### JSON mode (programmatic)
+
+For programmatic use (e.g., web dashboards, hosting platforms), use `--json` to get QR data as JSON instead of terminal output:
+
+```bash
+moltbot channels login --channel whatsapp --json --timeout 60000
+```
+
+This outputs two JSON objects:
+1. Initial response with QR data:
+```json
+{"status":"pending","qrDataUrl":"data:image/png;base64,...","message":"Scan this QR...","accountId":"default","channel":"whatsapp"}
+```
+
+2. Final response after scan (or timeout):
+```json
+{"status":"connected","connected":true,"message":"✅ Linked!","accountId":"default","channel":"whatsapp"}
+```
+
+The `qrDataUrl` is a base64-encoded PNG that can be displayed directly in an `
` tag.
+
## Troubleshooting
- Run `openclaw status --deep` for a broad probe.
diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts
index f7c9d85ea..210719a72 100644
--- a/src/cli/channel-auth.ts
+++ b/src/cli/channel-auth.ts
@@ -9,6 +9,8 @@ type ChannelAuthOptions = {
channel?: string;
account?: string;
verbose?: boolean;
+ json?: boolean;
+ timeoutMs?: number;
};
export async function runChannelLogin(
@@ -21,6 +23,54 @@ export async function runChannelLogin(
throw new Error(`Unsupported channel: ${channelInput}`);
}
const plugin = getChannelPlugin(channelId);
+
+ // JSON mode: use gateway QR login methods for programmatic access
+ if (opts.json) {
+ if (!plugin?.gateway?.loginWithQrStart || !plugin?.gateway?.loginWithQrWait) {
+ throw new Error(`Channel ${channelId} does not support JSON login mode`);
+ }
+ setVerbose(Boolean(opts.verbose));
+ const cfg = loadConfig();
+ const accountId = opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg });
+ const timeoutMs = opts.timeoutMs ?? 60000;
+
+ // Start QR login and get QR data
+ const startResult = await plugin.gateway.loginWithQrStart({
+ accountId,
+ force: false,
+ timeoutMs,
+ verbose: Boolean(opts.verbose),
+ });
+
+ // Output QR data as JSON
+ const output = {
+ status: "pending",
+ qrDataUrl: startResult.qrDataUrl ?? null,
+ message: startResult.message,
+ accountId,
+ channel: channelId,
+ };
+ runtime.log(JSON.stringify(output));
+
+ // Wait for connection
+ const waitResult = await plugin.gateway.loginWithQrWait({
+ accountId,
+ timeoutMs,
+ });
+
+ // Output final result
+ const finalOutput = {
+ status: waitResult.connected ? "connected" : "failed",
+ connected: waitResult.connected,
+ message: waitResult.message,
+ accountId,
+ channel: channelId,
+ };
+ runtime.log(JSON.stringify(finalOutput));
+ return;
+ }
+
+ // Standard interactive mode
if (!plugin?.auth?.login) {
throw new Error(`Channel ${channelId} does not support login`);
}
diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts
index dd60016d4..81ea2601b 100644
--- a/src/cli/channels-cli.ts
+++ b/src/cli/channels-cli.ts
@@ -215,6 +215,8 @@ export function registerChannelsCli(program: Command) {
.option("--channel ", "Channel alias (default: whatsapp)")
.option("--account ", "Account id (accountId)")
.option("--verbose", "Verbose connection logs", false)
+ .option("--json", "Output QR data as JSON (for programmatic use)", false)
+ .option("--timeout ", "Timeout for QR generation in ms", "60000")
.action(async (opts) => {
await runChannelsCommandWithDanger(async () => {
await runChannelLogin(
@@ -222,6 +224,8 @@ export function registerChannelsCli(program: Command) {
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
verbose: Boolean(opts.verbose),
+ json: Boolean(opts.json),
+ timeoutMs: parseInt(String(opts.timeout), 10) || 60000,
},
defaultRuntime,
);
diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts
index f6b155554..3f3465c5e 100644
--- a/src/cli/program.smoke.test.ts
+++ b/src/cli/program.smoke.test.ts
@@ -208,7 +208,7 @@ describe("cli program (smoke)", () => {
from: "user",
});
expect(runChannelLogin).toHaveBeenCalledWith(
- { channel: undefined, account: "work", verbose: false },
+ { channel: undefined, account: "work", verbose: false, json: false, timeoutMs: 60000 },
runtime,
);
});