From 220e566f19f64663d4c75732aad80388a8aa1505 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:38:08 -0700 Subject: [PATCH 1/5] feat(mobile): create and copy webhook automations Scheduled tasks on mobile can use the webhook trigger: copy its URL, rotate it, and keep a signature check configured on desktop or web. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SettingsScheduledTasksRouteScreen.tsx | 103 +++++++++++++++++- .../settings/scheduledTaskDraft.test.ts | 19 ++++ .../features/settings/scheduledTaskDraft.ts | 38 +++++-- 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 2effae896fc6..9afc956ba279 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -37,6 +37,7 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { SegmentedControl } from "../../components/SegmentedControl"; import { ThemedSwitch } from "../../components/ThemedSwitch"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { buildModelOptions } from "../../lib/modelOptions"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; @@ -56,6 +57,7 @@ import { SettingsSection } from "./components/SettingsSection"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; import { editDraft, + DEFAULT_WEBHOOK_PROMPT, scheduledTaskDefaultModel, scheduleFromDraft, type ScheduledTaskDraft as Draft, @@ -811,12 +813,20 @@ function TaskForm({ { setTimePickerOpen(false); - setDraft({ ...draft, schedule: { ...draft.schedule, mode } }); + setDraft({ + ...draft, + prompt: + mode === "webhook" && !draft.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : draft.prompt, + schedule: { ...draft.schedule, mode }, + }); }} /> @@ -890,6 +900,14 @@ function TaskForm({ }} /> + ) : draft.schedule.mode === "webhook" ? ( + task.id === draft.task?.id) ?? draft.task ?? null + } + signatureConfigured={draft.schedule.signature !== null} + /> ) : ( <> + + { + "The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees." + } + + {task === null || address === null ? ( + Save the task to get its webhook URL. + ) : ( + <> + void tryCopyTextWithHaptic(address)} + className="gap-1 active:opacity-70" + > + Webhook URL + + {address} + + + {webhook?.url === null ? ( + + Link this environment to T3 Connect for a public URL. + + ) : null} + + Alert.alert("Rotate URL?", "The current URL stops working immediately.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Rotate", + style: "destructive", + onPress: () => + void rotate({ environmentId, input: { id: task.id } }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + Alert.alert( + "Could not rotate URL", + String(squashAtomCommandFailure(result)), + ); + } + }), + }, + ]) + } + className="min-h-11 justify-center active:opacity-70" + > + Rotate URL + + + )} + {signatureConfigured ? ( + + Signature check configured on desktop/web. + + ) : null} + + ); +} + function EnvironmentTasks({ environment, now, @@ -1053,7 +1149,8 @@ function EnvironmentTasks({ actions={[ ...(task.schedule.type === "webhook" ? [] : [{ id: "edit", title: "Edit" }]), { id: "toggle", title: task.enabled ? "Pause" : "Resume" }, - { id: "run", title: "Run now" }, + // A webhook task has no request to run without. + ...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]), { id: "delete", title: "Delete", attributes: { destructive: true } }, ]} onPressAction={({ nativeEvent }) => { diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 01961597c3ad..022a357404ed 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -28,6 +28,25 @@ describe("scheduleDraftForTask", () => { const schedule = { type: "interval" as const, everyMs: 65_000 }; expect(scheduleFromDraft(scheduleDraftForTask({ schedule }))).toEqual(schedule); }); + + it("round-trips a webhook schedule without a signature", () => { + const draft = scheduleDraftForTask({ schedule: { type: "webhook", signature: null } }); + expect(draft.mode).toBe("webhook"); + expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null }); + }); + + it("keeps a webhook signature on save without sending a secret", () => { + const signature = { + header: "x-hub-signature-256", + encoding: "hex" as const, + prefix: "sha256=", + }; + const saved = scheduleFromDraft( + scheduleDraftForTask({ schedule: { type: "webhook", signature } }), + ); + expect(saved).toEqual({ type: "webhook", signature }); + expect(saved?.type === "webhook" && saved.signature && "secret" in saved.signature).toBe(false); + }); }); describe("hasScheduledTaskDraftChanges", () => { diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 9ec77b585ac8..2843700620e8 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -5,6 +5,7 @@ import type { RuntimeMode, ScheduledTask, ScheduledTaskUpsertSchedule, + ScheduledTaskWebhookSignature, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -37,10 +38,12 @@ export function scheduledTaskDefaultModel( } export type ScheduleDraft = { - readonly mode: "fixed_time" | "interval"; + readonly mode: "fixed_time" | "interval" | "webhook"; readonly timeOfDay: string; readonly weekdays: ReadonlyArray; readonly intervalMinutes: string; + /** A webhook signature check configured elsewhere; mobile keeps it but does not edit it. */ + readonly signature: ScheduledTaskWebhookSignature | null; }; export const DEFAULT_SCHEDULE: ScheduleDraft = { @@ -48,27 +51,48 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5], intervalMinutes: "15", + signature: null, }; +/** Prompt a new webhook task starts with: the whole request, which the user can narrow down. */ +export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{request}}"; + export function scheduleDraftForTask(task: Pick): ScheduleDraft { - // Webhook tasks cannot be edited here yet; show them as the default schedule. - if (task.schedule.type === "webhook") return DEFAULT_SCHEDULE; - return task.schedule.type === "fixed_time" - ? { + switch (task.schedule.type) { + case "fixed_time": + return { ...DEFAULT_SCHEDULE, timeOfDay: task.schedule.timeOfDay, weekdays: task.schedule.weekdays?.length ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6], - } - : { + }; + case "interval": + return { ...DEFAULT_SCHEDULE, mode: "interval", intervalMinutes: String(Math.max(1, task.schedule.everyMs / 60_000)), }; + case "webhook": + return { ...DEFAULT_SCHEDULE, mode: "webhook", signature: task.schedule.signature }; + } } export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { + if (draft.mode === "webhook") { + // No secret is sent, so the server keeps the stored one. + return { + type: "webhook", + signature: + draft.signature === null + ? null + : { + header: draft.signature.header, + encoding: draft.signature.encoding, + prefix: draft.signature.prefix, + }, + }; + } if (draft.mode === "interval") { const minutes = Number(draft.intervalMinutes); // Undo floating-point noise from displaying existing millisecond intervals as minutes. From 292f742ff6de23bac48c04a9825e4fc6aa1ca64b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:06:44 -0700 Subject: [PATCH 2/5] feat(mobile): webhook tasks are editable again Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/settings/SettingsScheduledTasksRouteScreen.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 9afc956ba279..35ffc43d7971 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1121,8 +1121,6 @@ function EnvironmentTasks({ { onEdit(task); }} @@ -1147,7 +1145,7 @@ function EnvironmentTasks({ Date: Sat, 3 Oct 2026 07:54:34 -0700 Subject: [PATCH 3/5] fix(mobile): saving keeps the current webhook signature and copies a full URL Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SettingsScheduledTasksRouteScreen.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 35ffc43d7971..b5b4a1f42a38 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -38,6 +38,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollVie import { SegmentedControl } from "../../components/SegmentedControl"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { usePreparedConnection } from "../../state/session"; import { buildModelOptions } from "../../lib/modelOptions"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; @@ -601,7 +602,14 @@ function TaskForm({ environmentUnavailable ) return; - const schedule = scheduleFromDraft(draft.schedule); + // Signatures are edited on desktop and web; send the task's current one, + // not the copy taken when this form opened, so a newer edit survives. + const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id); + const schedule = scheduleFromDraft( + draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook" + ? { ...draft.schedule, signature: liveTask.schedule.signature } + : draft.schedule, + ); if ( !draft.title.trim() || !draft.prompt.trim() || @@ -976,8 +984,14 @@ function WebhookScheduleDetails({ label: "scheduled task rotate webhook token", reportFailure: false, }); + const preparedConnection = usePreparedConnection(environmentId); + const httpBaseUrl = + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null; const webhook = task?.schedule.type === "webhook" ? task.webhook : undefined; - const address = webhook ? (webhook.url ?? webhook.path) : null; + // Without T3 Connect, the path is resolved on the address this phone uses. + const address = webhook + ? (webhook.url ?? (httpBaseUrl ? new URL(webhook.path, httpBaseUrl).href : webhook.path)) + : null; return ( From 8d93756afedb8c2b239cd1678cbc01e7bb01dc2d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:21:07 -0700 Subject: [PATCH 4/5] fix(mobile): only a full webhook URL can be copied Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/settings/SettingsScheduledTasksRouteScreen.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index b5b4a1f42a38..80d118fb1e0e 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1007,10 +1007,14 @@ function WebhookScheduleDetails({ accessibilityRole="button" accessibilityLabel="Copy webhook URL" accessibilityHint="Copies the URL to the clipboard" + // A bare path is not something a sender can call, so only full URLs copy. + disabled={!address.startsWith("http")} onPress={() => void tryCopyTextWithHaptic(address)} className="gap-1 active:opacity-70" > - Webhook URL + + {address.startsWith("http") ? "Webhook URL" : "Webhook path"} + {address} From 29e8e1ae19a8489520c238a539daf1cd55378c34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 5 Oct 2026 12:07:07 -0700 Subject: [PATCH 5/5] feat(mobile,client-runtime): webhook URLs work without T3 Connect and say who can reach them Without T3 Connect the webhook URL is built on the address the client reaches the environment at, and a note says whether senders can reach it (over Tailscale or a proxy) or only this computer can. The logic is shared so web shows the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../SettingsScheduledTasksRouteScreen.tsx | 21 +++++----- packages/client-runtime/package.json | 4 ++ .../client-runtime/src/webhookAddress.test.ts | 36 +++++++++++++++++ packages/client-runtime/src/webhookAddress.ts | 40 +++++++++++++++++++ 4 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 packages/client-runtime/src/webhookAddress.test.ts create mode 100644 packages/client-runtime/src/webhookAddress.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 80d118fb1e0e..b3a949e65e59 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -37,6 +37,7 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { SegmentedControl } from "../../components/SegmentedControl"; import { ThemedSwitch } from "../../components/ThemedSwitch"; +import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { usePreparedConnection } from "../../state/session"; import { buildModelOptions } from "../../lib/modelOptions"; @@ -989,9 +990,7 @@ function WebhookScheduleDetails({ preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null; const webhook = task?.schedule.type === "webhook" ? task.webhook : undefined; // Without T3 Connect, the path is resolved on the address this phone uses. - const address = webhook - ? (webhook.url ?? (httpBaseUrl ? new URL(webhook.path, httpBaseUrl).href : webhook.path)) - : null; + const resolved = webhook ? webhookAddress(webhook, httpBaseUrl) : null; return ( @@ -999,7 +998,7 @@ function WebhookScheduleDetails({ "The prompt can use {{body.a.b}}, {{headers.name}}, {{query.name}}, {{body}} and {{request}}. The filled-in prompt is all the agent sees." } - {task === null || address === null ? ( + {task === null || resolved === null ? ( Save the task to get its webhook URL. ) : ( <> @@ -1008,21 +1007,19 @@ function WebhookScheduleDetails({ accessibilityLabel="Copy webhook URL" accessibilityHint="Copies the URL to the clipboard" // A bare path is not something a sender can call, so only full URLs copy. - disabled={!address.startsWith("http")} - onPress={() => void tryCopyTextWithHaptic(address)} + disabled={!resolved.copyable} + onPress={() => void tryCopyTextWithHaptic(resolved.address)} className="gap-1 active:opacity-70" > - {address.startsWith("http") ? "Webhook URL" : "Webhook path"} + {resolved.copyable ? "Webhook URL" : "Webhook path"} - {address} + {resolved.address} - {webhook?.url === null ? ( - - Link this environment to T3 Connect for a public URL. - + {resolved.note !== null ? ( + {resolved.note} ) : null} ({ path, url, hasSecret: false }); + +describe("webhookAddress", () => { + it("uses the T3 Connect URL when the server has one", () => { + expect(webhookAddress(endpoint("https://relay.t3.codes/v1/hooks/k/t/x"), null)).toEqual({ + address: "https://relay.t3.codes/v1/hooks/k/t/x", + copyable: true, + note: null, + }); + }); + + it("builds a direct URL on the environment's address without T3 Connect", () => { + const result = webhookAddress(endpoint(null), "https://mac.tail1234.ts.net/"); + expect(result.address).toBe(`https://mac.tail1234.ts.net${path}`); + expect(result.copyable).toBe(true); + expect(result.note).toContain("Tailscale"); + }); + + it("says only this computer can call a loopback address", () => { + const result = webhookAddress(endpoint(null), "http://127.0.0.1:3773/"); + expect(result.copyable).toBe(true); + expect(result.note).toContain("Only this computer"); + }); + + it("falls back to the path when the address is unknown", () => { + expect(webhookAddress(endpoint(null), null)).toMatchObject({ + address: path, + copyable: false, + }); + }); +}); diff --git a/packages/client-runtime/src/webhookAddress.ts b/packages/client-runtime/src/webhookAddress.ts new file mode 100644 index 000000000000..cd1424fd6f86 --- /dev/null +++ b/packages/client-runtime/src/webhookAddress.ts @@ -0,0 +1,40 @@ +import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts"; +import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; + +/** + * Where a sender can call a webhook task, as a client shows it. With T3 + * Connect the server returns a public URL; without it the path is resolved on + * the address this client reaches the environment at. + */ +export interface WebhookAddress { + /** The URL to give a sender, or the bare path when no address is known. */ + readonly address: string; + /** Whether `address` is a full URL a sender can call. */ + readonly copyable: boolean; + /** One line on who can reach `address`; null for a T3 Connect URL. */ + readonly note: string | null; +} + +export function webhookAddress( + endpoint: ScheduledTaskWebhookEndpoint, + httpBaseUrl: string | null, +): WebhookAddress { + if (endpoint.url !== null) { + return { address: endpoint.url, copyable: true, note: null }; + } + if (httpBaseUrl === null) { + return { + address: endpoint.path, + copyable: false, + note: "Link this environment to T3 Connect for a public URL.", + }; + } + const url = new URL(endpoint.path, httpBaseUrl); + return { + address: url.href, + copyable: true, + note: isLocalLoopbackHost(url.hostname) + ? "Only this computer can call this address. Link T3 Connect for a public URL." + : "Works wherever this environment's address is reachable, for example over Tailscale or your own proxy. Link T3 Connect for a public URL.", + }; +}