diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1edda7aab..e57c7b4b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Status: unreleased.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Docs: add migration guide for moving to a new machine. (#2381)
+- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
@@ -43,6 +44,7 @@ Status: unreleased.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
+- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
@@ -51,7 +53,9 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
+- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
+- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
diff --git a/README.md b/README.md
index 535cd1c75..3f8853b93 100644
--- a/README.md
+++ b/README.md
@@ -477,35 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/docs.json b/docs/docs.json
index 2cc5ae78b..c53902451 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -805,6 +805,10 @@
"source": "/install/railway/",
"destination": "/railway"
},
+ {
+ "source": "/install/northflank/",
+ "destination": "/northflank"
+ },
{
"source": "/gcp",
"destination": "/platforms/gcp"
@@ -852,6 +856,7 @@
"install/docker",
"railway",
"render",
+ "northflank",
"install/bun"
]
},
diff --git a/docs/northflank.mdx b/docs/northflank.mdx
new file mode 100644
index 000000000..aae9c6a22
--- /dev/null
+++ b/docs/northflank.mdx
@@ -0,0 +1,53 @@
+---
+title: Deploy on Northflank
+---
+
+Deploy Clawdbot on Northflank with a one-click template and finish setup in your browser.
+This is the easiest “no terminal on the server” path: Northflank runs the Gateway for you,
+and you configure everything via the `/setup` web wizard.
+
+## How to get started
+
+1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template.
+2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one.
+3. Click **Deploy Clawdbot now**.
+4. Set the required environment variable: `SETUP_PASSWORD`.
+5. Click **Deploy stack** to build and run the Clawdbot template.
+6. Wait for the deployment to complete, then click **View resources**.
+7. Open the Clawdbot service.
+8. Open the public Clawdbot URL and complete setup at `/setup`.
+9. Open the Control UI at `/clawdbot`.
+
+## What you get
+
+- Hosted Clawdbot Gateway + Control UI
+- Web setup wizard at `/setup` (no terminal commands)
+- Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys
+
+## Setup flow
+
+1) Visit `https:///setup` and enter your `SETUP_PASSWORD`.
+2) Choose a model/auth provider and paste your key.
+3) (Optional) Add Telegram/Discord/Slack tokens.
+4) Click **Run setup**.
+5) Open the Control UI at `https:///clawdbot`
+
+If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
+
+## Getting chat tokens
+
+### Telegram bot token
+
+1) Message `@BotFather` in Telegram
+2) Run `/newbot`
+3) Copy the token (looks like `123456789:AA...`)
+4) Paste it into `/setup`
+
+### Discord bot token
+
+1) Go to https://discord.com/developers/applications
+2) **New Application** → choose a name
+3) **Bot** → **Add Bot**
+4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
+5) Copy the **Bot Token** and paste into `/setup`
+6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md
index cd574b26e..46713c939 100644
--- a/docs/plugins/voice-call.md
+++ b/docs/plugins/voice-call.md
@@ -104,6 +104,7 @@ Notes:
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced.
+- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
## TTS for calls
diff --git a/docs/vps.md b/docs/vps.md
index 192ab830e..08910733f 100644
--- a/docs/vps.md
+++ b/docs/vps.md
@@ -11,6 +11,8 @@ deployments work at a high level.
## Pick a provider
+- **Railway** (one‑click + browser setup): [Railway](/railway)
+- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index a8721d47d..588817858 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -6,6 +6,7 @@
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
+- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`.
## 2026.1.23
diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md
index d96f90392..5f009aa28 100644
--- a/extensions/voice-call/README.md
+++ b/extensions/voice-call/README.md
@@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`:
Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
+- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
## TTS for calls
diff --git a/extensions/voice-call/clawdbot.plugin.json b/extensions/voice-call/clawdbot.plugin.json
index 2a4f04466..cfac7ad9d 100644
--- a/extensions/voice-call/clawdbot.plugin.json
+++ b/extensions/voice-call/clawdbot.plugin.json
@@ -78,8 +78,8 @@
"label": "ngrok Domain",
"advanced": true
},
- "tunnel.allowNgrokFreeTier": {
- "label": "Allow ngrok Free Tier",
+ "tunnel.allowNgrokFreeTierLoopbackBypass": {
+ "label": "Allow ngrok Free Tier (Loopback Bypass)",
"advanced": true
},
"streaming.enabled": {
@@ -330,7 +330,7 @@
"ngrokDomain": {
"type": "string"
},
- "allowNgrokFreeTier": {
+ "allowNgrokFreeTierLoopbackBypass": {
"type": "boolean"
}
}
diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts
index 60076bbe2..60cb64eb2 100644
--- a/extensions/voice-call/index.ts
+++ b/extensions/voice-call/index.ts
@@ -62,8 +62,8 @@ const voiceCallConfigSchema = {
advanced: true,
},
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
- "tunnel.allowNgrokFreeTier": {
- label: "Allow ngrok Free Tier",
+ "tunnel.allowNgrokFreeTierLoopbackBypass": {
+ label: "Allow ngrok Free Tier (Loopback Bypass)",
advanced: true,
},
"streaming.enabled": { label: "Enable Streaming", advanced: true },
diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts
index aac9fe44c..dde17e122 100644
--- a/extensions/voice-call/src/config.test.ts
+++ b/extensions/voice-call/src/config.test.ts
@@ -19,7 +19,7 @@ function createBaseConfig(
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
- tunnel: { provider: "none", allowNgrokFreeTier: false },
+ tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
streaming: {
enabled: false,
sttProvider: "openai-realtime",
diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts
index 99916e49d..7784406e7 100644
--- a/extensions/voice-call/src/config.ts
+++ b/extensions/voice-call/src/config.ts
@@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z
/**
* Allow ngrok free tier compatibility mode.
* When true, signature verification failures on ngrok-free.app URLs
- * will include extra diagnostics. Signature verification is still required.
+ * will be allowed only for loopback requests (ngrok local agent).
*/
- allowNgrokFreeTier: z.boolean().default(false),
+ allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
+ /**
+ * Legacy ngrok free tier compatibility mode (deprecated).
+ * Use allowNgrokFreeTierLoopbackBypass instead.
+ */
+ allowNgrokFreeTier: z.boolean().optional(),
})
.strict()
- .default({ provider: "none", allowNgrokFreeTier: false });
+ .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer;
// -----------------------------------------------------------------------------
@@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
// Tunnel Config
resolved.tunnel = resolved.tunnel ?? {
provider: "none",
- allowNgrokFreeTier: false,
+ allowNgrokFreeTierLoopbackBypass: false,
};
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
+ resolved.tunnel.allowNgrokFreeTier ||
+ false;
resolved.tunnel.ngrokAuthToken =
resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain =
diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts
index be9dd6eda..87c0f244d 100644
--- a/extensions/voice-call/src/providers/twilio.ts
+++ b/extensions/voice-call/src/providers/twilio.ts
@@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
* @see https://www.twilio.com/docs/voice/media-streams
*/
export interface TwilioProviderOptions {
- /** Allow ngrok free tier compatibility mode (less secure) */
- allowNgrokFreeTier?: boolean;
+ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
+ allowNgrokFreeTierLoopbackBypass?: boolean;
/** Override public URL for signature verification */
publicUrl?: string;
/** Path for media stream WebSocket (e.g., /voice/stream) */
diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts
index 1cddcb164..d5c3abb95 100644
--- a/extensions/voice-call/src/providers/twilio/webhook.ts
+++ b/extensions/voice-call/src/providers/twilio/webhook.ts
@@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: {
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
- allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false,
+ allowNgrokFreeTierLoopbackBypass:
+ params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
});
diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts
index ffa95ddff..6f638ab5b 100644
--- a/extensions/voice-call/src/runtime.ts
+++ b/extensions/voice-call/src/runtime.ts
@@ -33,7 +33,19 @@ type Logger = {
debug: (message: string) => void;
};
+function isLoopbackBind(bind: string | undefined): boolean {
+ if (!bind) return false;
+ return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
+}
+
function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
+ const allowNgrokFreeTierLoopbackBypass =
+ config.tunnel?.provider === "ngrok" &&
+ isLoopbackBind(config.serve?.bind) &&
+ (config.tunnel?.allowNgrokFreeTierLoopbackBypass ||
+ config.tunnel?.allowNgrokFreeTier ||
+ false);
+
switch (config.provider) {
case "telnyx":
return new TelnyxProvider({
@@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken,
},
{
- allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false,
+ allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled
diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts
index 7f3928778..68cca11e6 100644
--- a/extensions/voice-call/src/types.ts
+++ b/extensions/voice-call/src/types.ts
@@ -180,6 +180,7 @@ export type WebhookContext = {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
query?: Record;
+ remoteAddress?: string;
};
export type ProviderWebhookParseResult = {
diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts
index 98d8a451c..3db2983ec 100644
--- a/extensions/voice-call/src/webhook-security.test.ts
+++ b/extensions/voice-call/src/webhook-security.test.ts
@@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
+ remoteAddress: "203.0.113.10",
},
authToken,
- { allowNgrokFreeTier: true },
+ { allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/Invalid signature/);
});
+
+ it("allows invalid signatures for ngrok free tier only on loopback", () => {
+ const authToken = "test-auth-token";
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
+
+ const result = verifyTwilioWebhook(
+ {
+ headers: {
+ host: "127.0.0.1:3334",
+ "x-forwarded-proto": "https",
+ "x-forwarded-host": "local.ngrok-free.app",
+ "x-twilio-signature": "invalid",
+ },
+ rawBody: postBody,
+ url: "http://127.0.0.1:3334/voice/webhook",
+ method: "POST",
+ remoteAddress: "127.0.0.1",
+ },
+ authToken,
+ { allowNgrokFreeTierLoopbackBypass: true },
+ );
+
+ expect(result.ok).toBe(true);
+ expect(result.isNgrokFreeTier).toBe(true);
+ expect(result.reason).toMatch(/compatibility mode/);
+ });
});
diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts
index 98b1d9837..6c7d4d9ab 100644
--- a/extensions/voice-call/src/webhook-security.ts
+++ b/extensions/voice-call/src/webhook-security.ts
@@ -131,6 +131,13 @@ function getHeader(
return value;
}
+function isLoopbackAddress(address?: string): boolean {
+ if (!address) return false;
+ if (address === "127.0.0.1" || address === "::1") return true;
+ if (address.startsWith("::ffff:127.")) return true;
+ return false;
+}
+
/**
* Result of Twilio webhook verification with detailed info.
*/
@@ -155,8 +162,8 @@ export function verifyTwilioWebhook(
options?: {
/** Override the public URL (e.g., from config) */
publicUrl?: string;
- /** Allow ngrok free tier compatibility mode (less secure) */
- allowNgrokFreeTier?: boolean;
+ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
+ allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
},
@@ -195,6 +202,22 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io");
+ if (
+ isNgrokFreeTier &&
+ options?.allowNgrokFreeTierLoopbackBypass &&
+ isLoopbackAddress(ctx.remoteAddress)
+ ) {
+ console.warn(
+ "[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
+ );
+ return {
+ ok: true,
+ reason: "ngrok free tier compatibility mode (loopback only)",
+ verificationUrl,
+ isNgrokFreeTier: true,
+ };
+ }
+
return {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,
diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts
index 6ab4d0eed..09e96ffed 100644
--- a/extensions/voice-call/src/webhook.ts
+++ b/extensions/voice-call/src/webhook.ts
@@ -252,6 +252,7 @@ export class VoiceCallWebhookServer {
url: `http://${req.headers.host}${req.url}`,
method: "POST",
query: Object.fromEntries(url.searchParams),
+ remoteAddress: req.socket.remoteAddress ?? undefined,
};
// Verify signature
diff --git a/package.json b/package.json
index 0c63d5d69..1299d72d5 100644
--- a/package.json
+++ b/package.json
@@ -237,6 +237,9 @@
"vitest": "^4.0.18",
"wireit": "^0.14.12"
},
+ "overrides": {
+ "tar": "7.5.4"
+ },
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts
index c167ac32a..891ab2b45 100644
--- a/src/agents/tools/telegram-actions.ts
+++ b/src/agents/tools/telegram-actions.ts
@@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
+ editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
@@ -209,5 +210,50 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, deleted: true });
}
+ if (action === "editMessage") {
+ if (!isActionEnabled("editMessage")) {
+ throw new Error("Telegram editMessage is disabled.");
+ }
+ const chatId = readStringOrNumberParam(params, "chatId", {
+ required: true,
+ });
+ const messageId = readNumberParam(params, "messageId", {
+ required: true,
+ integer: true,
+ });
+ const content = readStringParam(params, "content", {
+ required: true,
+ allowEmpty: false,
+ });
+ const buttons = readTelegramButtons(params);
+ if (buttons) {
+ const inlineButtonsScope = resolveTelegramInlineButtonsScope({
+ cfg,
+ accountId: accountId ?? undefined,
+ });
+ if (inlineButtonsScope === "off") {
+ throw new Error(
+ 'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".',
+ );
+ }
+ }
+ const token = resolveTelegramToken(cfg, { accountId }).token;
+ if (!token) {
+ throw new Error(
+ "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
+ );
+ }
+ const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, {
+ token,
+ accountId: accountId ?? undefined,
+ buttons,
+ });
+ return jsonResult({
+ ok: true,
+ messageId: result.messageId,
+ chatId: result.chatId,
+ });
+ }
+
throw new Error(`Unsupported Telegram action: ${action}`);
}
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 939fa92f0..3537972e4 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
+ isHeartbeat: params.isHeartbeat,
});
}
let runResult: Awaited>;
diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts
index bc59b4f2e..45ad44d5a 100644
--- a/src/auto-reply/reply/history.ts
+++ b/src/auto-reply/reply/history.ts
@@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
+/** Maximum number of group history keys to retain (LRU eviction when exceeded). */
+export const MAX_HISTORY_KEYS = 1000;
+
+/**
+ * Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS.
+ * Uses Map's insertion order for LRU-like behavior.
+ */
+export function evictOldHistoryKeys(
+ historyMap: Map,
+ maxKeys: number = MAX_HISTORY_KEYS,
+): void {
+ if (historyMap.size <= maxKeys) return;
+ const keysToDelete = historyMap.size - maxKeys;
+ const iterator = historyMap.keys();
+ for (let i = 0; i < keysToDelete; i++) {
+ const key = iterator.next().value;
+ if (key !== undefined) historyMap.delete(key);
+ }
+}
+
export type HistoryEntry = {
sender: string;
body: string;
@@ -34,7 +54,13 @@ export function appendHistoryEntry(params: {
const history = historyMap.get(historyKey) ?? [];
history.push(entry);
while (history.length > params.limit) history.shift();
+ if (historyMap.has(historyKey)) {
+ // Refresh insertion order so eviction keeps recently used histories.
+ historyMap.delete(historyKey);
+ }
historyMap.set(historyKey, history);
+ // Evict oldest keys if map exceeds max size to prevent unbounded memory growth
+ evictOldHistoryKeys(historyMap);
return history;
}
diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts
index 970a714d0..0fea27708 100644
--- a/src/auto-reply/reply/session-updates.ts
+++ b/src/auto-reply/reply/session-updates.ts
@@ -21,7 +21,11 @@ export async function prependSystemEvents(params: {
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) return null;
- if (lower.includes("heartbeat")) return null;
+ // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
+ // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
+ if (lower.startsWith("read heartbeat.md")) return null;
+ // Also filter heartbeat poll/wake noise
+ if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null;
if (trimmed.startsWith("Node:")) {
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}
diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts
index 6b79bf5ba..b2673134d 100644
--- a/src/channels/plugins/actions/telegram.test.ts
+++ b/src/channels/plugins/actions/telegram.test.ts
@@ -62,4 +62,53 @@ describe("telegramMessageActions", () => {
cfg,
);
});
+
+ it("maps edit action params into editMessage", async () => {
+ handleTelegramAction.mockClear();
+ const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
+
+ await telegramMessageActions.handleAction({
+ action: "edit",
+ params: {
+ chatId: "123",
+ messageId: 42,
+ message: "Updated",
+ buttons: [],
+ },
+ cfg,
+ accountId: undefined,
+ });
+
+ expect(handleTelegramAction).toHaveBeenCalledWith(
+ {
+ action: "editMessage",
+ chatId: "123",
+ messageId: 42,
+ content: "Updated",
+ buttons: [],
+ accountId: undefined,
+ },
+ cfg,
+ );
+ });
+
+ it("rejects non-integer messageId for edit before reaching telegram-actions", async () => {
+ handleTelegramAction.mockClear();
+ const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
+
+ await expect(
+ telegramMessageActions.handleAction({
+ action: "edit",
+ params: {
+ chatId: "123",
+ messageId: "nope",
+ message: "Updated",
+ },
+ cfg,
+ accountId: undefined,
+ }),
+ ).rejects.toThrow();
+
+ expect(handleTelegramAction).not.toHaveBeenCalled();
+ });
});
diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts
index e281772bd..364707e0a 100644
--- a/src/channels/plugins/actions/telegram.ts
+++ b/src/channels/plugins/actions/telegram.ts
@@ -1,5 +1,6 @@
import {
createActionGate,
+ readNumberParam,
readStringOrNumberParam,
readStringParam,
} from "../../../agents/tools/common.js";
@@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const actions = new Set(["send"]);
if (gate("reactions")) actions.add("react");
if (gate("deleteMessage")) actions.add("delete");
+ if (gate("editMessage")) actions.add("edit");
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
@@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
readStringOrNumberParam(params, "chatId") ??
readStringOrNumberParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
- const messageId = readStringParam(params, "messageId", {
+ const messageId = readNumberParam(params, "messageId", {
required: true,
+ integer: true,
});
return await handleTelegramAction(
{
action: "deleteMessage",
chatId,
- messageId: Number(messageId),
+ messageId,
+ accountId: accountId ?? undefined,
+ },
+ cfg,
+ );
+ }
+
+ if (action === "edit") {
+ const chatId =
+ readStringOrNumberParam(params, "chatId") ??
+ readStringOrNumberParam(params, "channelId") ??
+ readStringParam(params, "to", { required: true });
+ const messageId = readNumberParam(params, "messageId", {
+ required: true,
+ integer: true,
+ });
+ const message = readStringParam(params, "message", { required: true, allowEmpty: false });
+ const buttons = params.buttons;
+ return await handleTelegramAction(
+ {
+ action: "editMessage",
+ chatId,
+ messageId,
+ content: message,
+ buttons,
accountId: accountId ?? undefined,
},
cfg,
diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts
index 5d0b80e25..f6a7c3db8 100644
--- a/src/config/types.telegram.ts
+++ b/src/config/types.telegram.ts
@@ -15,6 +15,7 @@ export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
deleteMessage?: boolean;
+ editMessage?: boolean;
};
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts
index 9ef62e688..8c67767a6 100644
--- a/src/gateway/server-chat.ts
+++ b/src/gateway/server-chat.ts
@@ -1,8 +1,28 @@
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
+import { loadConfig } from "../config/config.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
+import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
+/**
+ * Check if webchat broadcasts should be suppressed for heartbeat runs.
+ * Returns true if the run is a heartbeat and showOk is false.
+ */
+function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
+ const runContext = getAgentRunContext(runId);
+ if (!runContext?.isHeartbeat) return false;
+
+ try {
+ const cfg = loadConfig();
+ const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
+ return !visibility.showOk;
+ } catch {
+ // Default to suppressing if we can't load config
+ return true;
+ }
+}
+
export type ChatRunEntry = {
sessionKey: string;
clientRunId: string;
@@ -130,7 +150,10 @@ export function createAgentEventHandler({
timestamp: now,
},
};
- broadcast("chat", payload, { dropIfSlow: true });
+ // Suppress webchat broadcast for heartbeat runs when showOk is false
+ if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
+ broadcast("chat", payload, { dropIfSlow: true });
+ }
nodeSendToSession(sessionKey, "chat", payload);
};
@@ -158,7 +181,10 @@ export function createAgentEventHandler({
}
: undefined,
};
- broadcast("chat", payload);
+ // Suppress webchat broadcast for heartbeat runs when showOk is false
+ if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
+ broadcast("chat", payload);
+ }
nodeSendToSession(sessionKey, "chat", payload);
return;
}
diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts
index 08415f346..b72939c6a 100644
--- a/src/gateway/server-http.ts
+++ b/src/gateway/server-http.ts
@@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
- } catch (err) {
+ } catch {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
- res.end(String(err));
+ res.end("Internal Server Error");
}
}
diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts
index c11dff8ab..5c41c3c95 100644
--- a/src/infra/agent-events.ts
+++ b/src/infra/agent-events.ts
@@ -14,6 +14,7 @@ export type AgentEventPayload = {
export type AgentRunContext = {
sessionKey?: string;
verboseLevel?: VerboseLevel;
+ isHeartbeat?: boolean;
};
// Keep per-run counters so streams stay strictly monotonic per runId.
@@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
existing.verboseLevel = context.verboseLevel;
}
+ if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
+ existing.isHeartbeat = context.isHeartbeat;
+ }
}
export function getAgentRunContext(runId: string) {
diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts
index 17a7dc128..e98054bbb 100644
--- a/src/infra/heartbeat-visibility.test.ts
+++ b/src/infra/heartbeat-visibility.test.ts
@@ -247,4 +247,58 @@ describe("resolveHeartbeatVisibility", () => {
useIndicator: true,
});
});
+
+ it("webchat uses channel defaults only (no per-channel config)", () => {
+ const cfg = {
+ channels: {
+ defaults: {
+ heartbeat: {
+ showOk: true,
+ showAlerts: false,
+ useIndicator: false,
+ },
+ },
+ },
+ } as ClawdbotConfig;
+
+ const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
+
+ expect(result).toEqual({
+ showOk: true,
+ showAlerts: false,
+ useIndicator: false,
+ });
+ });
+
+ it("webchat returns defaults when no channel defaults configured", () => {
+ const cfg = {} as ClawdbotConfig;
+
+ const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
+
+ expect(result).toEqual({
+ showOk: false,
+ showAlerts: true,
+ useIndicator: true,
+ });
+ });
+
+ it("webchat ignores accountId (only uses defaults)", () => {
+ const cfg = {
+ channels: {
+ defaults: {
+ heartbeat: {
+ showOk: true,
+ },
+ },
+ },
+ } as ClawdbotConfig;
+
+ const result = resolveHeartbeatVisibility({
+ cfg,
+ channel: "webchat",
+ accountId: "some-account",
+ });
+
+ expect(result.showOk).toBe(true);
+ });
});
diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts
index 75555b878..c24b10417 100644
--- a/src/infra/heartbeat-visibility.ts
+++ b/src/infra/heartbeat-visibility.ts
@@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
-import type { DeliverableMessageChannel } from "../utils/message-channel.js";
+import type { GatewayMessageChannel } from "../utils/message-channel.js";
export type ResolvedHeartbeatVisibility = {
showOk: boolean;
@@ -14,13 +14,28 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
useIndicator: true, // Emit indicator events
};
+/**
+ * Resolve heartbeat visibility settings for a channel.
+ * Supports both deliverable channels (telegram, signal, etc.) and webchat.
+ * For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config.
+ */
export function resolveHeartbeatVisibility(params: {
cfg: ClawdbotConfig;
- channel: DeliverableMessageChannel;
+ channel: GatewayMessageChannel;
accountId?: string;
}): ResolvedHeartbeatVisibility {
const { cfg, channel, accountId } = params;
+ // Webchat uses channel defaults only (no per-channel or per-account config)
+ if (channel === "webchat") {
+ const channelDefaults = cfg.channels?.defaults?.heartbeat;
+ return {
+ showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk,
+ showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts,
+ useIndicator: channelDefaults?.useIndicator ?? DEFAULT_VISIBILITY.useIndicator,
+ };
+ }
+
// Layer 1: Global channel defaults
const channelDefaults = cfg.channels?.defaults?.heartbeat;
diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts
index 9d5a4c4ce..eb26bf499 100644
--- a/src/infra/heartbeat-wake.ts
+++ b/src/infra/heartbeat-wake.ts
@@ -37,10 +37,10 @@ function schedule(coalesceMs: number) {
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
}
- } catch (err) {
+ } catch {
+ // Error is already logged by the heartbeat runner; schedule a retry.
pendingReason = reason ?? "retry";
schedule(DEFAULT_RETRY_MS);
- throw err;
} finally {
running = false;
if (pendingReason || scheduled) schedule(coalesceMs);
diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts
index 1006934d3..deebf7c70 100644
--- a/src/security/audit.test.ts
+++ b/src/security/audit.test.ts
@@ -44,6 +44,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
+ env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
@@ -88,6 +89,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
+ env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
diff --git a/src/security/audit.ts b/src/security/audit.ts
index 2169f197d..6cac2c37c 100644
--- a/src/security/audit.ts
+++ b/src/security/audit.ts
@@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: {
return findings;
}
-function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
+function collectGatewayConfigFindings(
+ cfg: ClawdbotConfig,
+ env: NodeJS.ProcessEnv,
+): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
- const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
+ const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
@@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise ({
+ botApi: {
+ editMessageText: vi.fn(),
+ },
+ botCtorSpy: vi.fn(),
+}));
+
+vi.mock("grammy", () => ({
+ Bot: class {
+ api = botApi;
+ constructor(public token: string) {
+ botCtorSpy(token);
+ }
+ },
+ InputFile: class {},
+}));
+
+import { editMessageTelegram } from "./send.js";
+
+describe("editMessageTelegram", () => {
+ beforeEach(() => {
+ botApi.editMessageText.mockReset();
+ botCtorSpy.mockReset();
+ });
+
+ it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
+ botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, "hi", {
+ token: "tok",
+ cfg: {},
+ });
+
+ expect(botCtorSpy).toHaveBeenCalledWith("tok");
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
+ const call = botApi.editMessageText.mock.calls[0] ?? [];
+ const params = call[3] as Record;
+ expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
+ expect(params).not.toHaveProperty("reply_markup");
+ });
+
+ it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
+ botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, "hi", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
+ const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(params).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+
+ it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
+ botApi.editMessageText
+ .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
+ .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, " html", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
+
+ const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(firstParams).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+
+ const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record;
+ expect(secondParams).toEqual(
+ expect.objectContaining({
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+});
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index f9557bf1e..43a3a5e8c 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -495,6 +495,99 @@ export async function deleteMessageTelegram(
return { ok: true };
}
+type TelegramEditOpts = {
+ token?: string;
+ accountId?: string;
+ verbose?: boolean;
+ api?: Bot["api"];
+ retry?: RetryConfig;
+ textMode?: "markdown" | "html";
+ /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */
+ buttons?: Array>;
+ /** Optional config injection to avoid global loadConfig() (improves testability). */
+ cfg?: ReturnType;
+};
+
+export async function editMessageTelegram(
+ chatIdInput: string | number,
+ messageIdInput: string | number,
+ text: string,
+ opts: TelegramEditOpts = {},
+): Promise<{ ok: true; messageId: string; chatId: string }> {
+ const cfg = opts.cfg ?? loadConfig();
+ const account = resolveTelegramAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+ const token = resolveToken(opts.token, account);
+ const chatId = normalizeChatId(String(chatIdInput));
+ const messageId = normalizeMessageId(messageIdInput);
+ const client = resolveTelegramClientOptions(account);
+ const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
+ const request = createTelegramRetryRunner({
+ retry: opts.retry,
+ configRetry: account.config.retry,
+ verbose: opts.verbose,
+ });
+ const logHttpError = createTelegramHttpLogger(cfg);
+ const requestWithDiag = (fn: () => Promise, label?: string) =>
+ request(fn, label).catch((err) => {
+ logHttpError(label ?? "request", err);
+ throw err;
+ });
+
+ const textMode = opts.textMode ?? "markdown";
+ const tableMode = resolveMarkdownTableMode({
+ cfg,
+ channel: "telegram",
+ accountId: account.accountId,
+ });
+ const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
+
+ // Reply markup semantics:
+ // - buttons === undefined → don't send reply_markup (keep existing)
+ // - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove)
+ // - otherwise → send built inline keyboard
+ const shouldTouchButtons = opts.buttons !== undefined;
+ const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined;
+ const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined;
+
+ const editParams: Record = {
+ parse_mode: "HTML",
+ };
+ if (replyMarkup !== undefined) {
+ editParams.reply_markup = replyMarkup;
+ }
+
+ await requestWithDiag(
+ () => api.editMessageText(chatId, messageId, htmlText, editParams),
+ "editMessage",
+ ).catch(async (err) => {
+ // Telegram rejects malformed HTML. Fall back to plain text.
+ const errText = formatErrorMessage(err);
+ if (PARSE_ERR_RE.test(errText)) {
+ if (opts.verbose) {
+ console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`);
+ }
+ const plainParams: Record = {};
+ if (replyMarkup !== undefined) {
+ plainParams.reply_markup = replyMarkup;
+ }
+ return await requestWithDiag(
+ () =>
+ Object.keys(plainParams).length > 0
+ ? api.editMessageText(chatId, messageId, text, plainParams)
+ : api.editMessageText(chatId, messageId, text),
+ "editMessage-plain",
+ );
+ }
+ throw err;
+ });
+
+ logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`);
+ return { ok: true, messageId: String(messageId), chatId };
+}
+
function inferFilename(kind: ReturnType) {
switch (kind) {
case "image":