From c73d4a07589c25a99054e4a62cb540c0a6addb62 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:43:16 -0700 Subject: [PATCH 1/8] feat(web): create webhook automations and inspect their deliveries Scheduled tasks get an "On webhook" trigger with a copyable URL, rotate, optional signature settings, and a deliveries dialog showing each request and the prompt it produced. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/ScheduledTasksSettings.tsx | 426 ++++++++++++++++-- .../scheduledTasksSettings.logic.test.ts | 40 ++ .../settings/scheduledTasksSettings.logic.ts | 68 ++- docs/user/project-settings.md | 25 + packages/client-runtime/src/state/server.ts | 16 +- 5 files changed, 537 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 64ba04cf2b3c..65f4ef5c23a3 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { Clock3Icon, + CopyIcon, + InboxIcon, MoreHorizontalIcon, PencilIcon, PlayIcon, @@ -17,6 +19,8 @@ import type { ScheduledTaskId, ScheduledTaskSchedule, ScheduledTaskUpsertInput, + ScheduledTaskWebhookDeliveryOutcome, + ScheduledTaskWebhookDeliverySummary, ThreadId, } from "@t3tools/contracts"; import { @@ -37,7 +41,13 @@ import { deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; -import { useEnvironment, type EnvironmentPresentation } from "../../state/environments"; +import { requestConfirmDialog } from "../../confirmDialog"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { + useEnvironment, + useEnvironmentHttpBaseUrl, + type EnvironmentPresentation, +} from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; @@ -46,10 +56,15 @@ import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { useSettingsScope } from "./SettingsScopeContext"; import { + DEFAULT_WEBHOOK_PROMPT, + WEBHOOK_SIGNATURE_DEFAULTS, matchesScheduledTaskScope, + scheduleFromDraft, scheduledTaskDefaultModel, taskToDraft, + webhookUrl, type DraftState, + type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; import { Label } from "../ui/label"; @@ -112,6 +127,9 @@ const EMPTY_DRAFT: DraftState = { runtimeMode: "full-access", interactionMode: "default", baseModelSelection: null, + signatureEnabled: false, + ...WEBHOOK_SIGNATURE_DEFAULTS, + signatureSecret: "", }; /** Labelled field: a caption sitting above its control. */ @@ -148,19 +166,6 @@ function splitModelKey(value: string): ModelSelection | null { }; } -function scheduleFromDraft(draft: DraftState): ScheduledTaskSchedule { - if (draft.scheduleMode === "interval") { - const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000); - return { type: "interval", everyMs }; - } - const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7; - return { - type: "fixed_time", - timeOfDay: draft.timeOfDay || "09:00", - ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }), - }; -} - export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; if (schedule.type === "interval") { @@ -200,6 +205,20 @@ export function relativeLabel(value: string | null): string { return `in ${Math.round(hours / 24)}d`; } +const DELIVERY_OUTCOME_LABELS: Record = { + accepted: "Ran", + dispatch_failed: "Run failed", + rejected_signature: "Bad signature", + disabled: "Task paused", + rate_limited: "Rate limited", +}; + +function deliveryOutcomeVariant(outcome: ScheduledTaskWebhookDeliveryOutcome) { + if (outcome === "accepted") return "success"; + if (outcome === "disabled" || outcome === "rate_limited") return "warning"; + return "error"; +} + function statusVariant(status: ScheduledTask["lastRunStatus"]) { if (status === "failed") return "error"; if (status === "succeeded") return "success"; @@ -385,6 +404,8 @@ function ScheduledTaskRow({ readonly onEdit: () => void; }) { const [busy, setBusy] = useState(false); + const [deliveriesOpen, setDeliveriesOpen] = useState(false); + const isWebhook = task.schedule.type === "webhook"; const toggle = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { label: "scheduled task enabled", }); @@ -422,11 +443,13 @@ function ScheduledTaskRow({
{scheduleLabel(task.schedule)} ·{" "} - {task.enabled - ? task.nextRunAt - ? `Next run ${relativeLabel(task.nextRunAt)}` - : "Not scheduled" - : "Paused"} + {!task.enabled + ? "Paused" + : isWebhook + ? "Listening" + : task.nextRunAt + ? `Next run ${relativeLabel(task.nextRunAt)}` + : "Not scheduled"} {task.lastRunStatus !== "never" ? ( {task.lastRunStatus} @@ -461,10 +484,17 @@ function ScheduledTaskRow({ Edit - void act("run")} disabled={task.schedule.type === "webhook"}> - - Run now - + {isWebhook ? ( + setDeliveriesOpen(true)}> + + Deliveries + + ) : ( + void act("run")}> + + Run now + + )} void act("delete")}> @@ -472,12 +502,240 @@ function ScheduledTaskRow({ + {deliveriesOpen ? ( + setDeliveriesOpen(false)} + /> + ) : null}
} /> ); } +function WebhookDeliveriesDialog({ + environmentId, + task, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly task: ScheduledTask; + readonly onClose: () => void; +}) { + const deliveriesQuery = useEnvironmentQuery( + serverEnvironment.scheduledTaskWebhookDeliveries({ environmentId, input: { id: task.id } }), + ); + const [selectedId, setSelectedId] = useState( + null, + ); + const selectedQuery = useEnvironmentQuery( + selectedId === null + ? null + : serverEnvironment.scheduledTaskWebhookDelivery({ + environmentId, + input: { id: task.id, deliveryId: selectedId }, + }), + ); + const deliveries = deliveriesQuery.data?.deliveries ?? null; + const selected = selectedQuery.data?.delivery ?? null; + const error = selectedId === null ? deliveriesQuery.error : selectedQuery.error; + + return ( + { + if (!next) onClose(); + }} + > + + + Deliveries · {task.title} + Recent requests to this task's webhook URL. + + + {error ?

{error}

: null} + {selectedId !== null && selected === null ? ( +

+ Loading delivery… +

+ ) : selected ? ( +
+
+ + {DELIVERY_OUTCOME_LABELS[selected.outcome]} + + + {selected.method} · {relativeLabel(selected.receivedAt)} + + {selected.signatureVerified ? ( + Signature verified + ) : null} +
+ {selected.error ?

{selected.error}

: null} + + {selected.renderedPrompt ?? "No run was started for this request."} + + {selected.missingFields.length > 0 ? ( +

+ Empty placeholders: {selected.missingFields.join(", ")} +

+ ) : null} + + {Object.entries(selected.headers) + .map(([name, value]) => `${name}: ${value}`) + .join("\n")} + + {selected.query ? ( + {selected.query} + ) : null} + + {selected.body || "(empty)"} + +
+ ) : deliveries === null ? ( +

+ Loading deliveries… +

+ ) : deliveries.length === 0 ? ( +

No requests yet.

+ ) : ( +
    + {deliveries.map((delivery) => ( +
  • + +
  • + ))} +
+ )} +
+ + {selectedId !== null ? ( + + ) : ( + + )} + }>Done + +
+
+ ); +} + +function DeliveryBlock({ title, children }: { title: string; children: ReactNode }) { + return ( +
+ +
+        {children}
+      
+
+ ); +} + +function WebhookEndpointField({ + environmentId, + task, +}: { + readonly environmentId: EnvironmentId; + readonly task: ScheduledTask | null; +}) { + const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); + const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" }); + const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, { + label: "scheduled task rotate webhook token", + }); + const [rotating, setRotating] = useState(false); + const endpoint = task?.webhook; + if (!task || !endpoint) { + return

The URL appears after you save.

; + } + const url = webhookUrl(endpoint, httpBaseUrl); + const rotateUrl = async () => { + const confirmed = + (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", { + variant: "destructive", + })) ?? true; + if (!confirmed) return; + setRotating(true); + const result = await rotate({ environmentId, input: { id: task.id } }); + setRotating(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not rotate webhook URL", + description: String(squashAtomCommandFailure(result)), + }), + ); + } + }; + return ( +
+
+ event.currentTarget.select()} + /> + + +
+ {endpoint.url === null ? ( +

+ Link this environment to T3 Connect for a public URL. This address works wherever the + environment is reachable. +

+ ) : null} +
+ ); +} + function ScheduledTaskEditorDialog({ initialEnvironmentId, task, @@ -527,6 +785,8 @@ function ScheduledTaskEditorDialog({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + // The live row, so a rotated URL shows up without reopening the dialog. + const liveTask = tasksQuery.data?.tasks.find((entry) => entry.id === draft.editingId) ?? task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); @@ -574,6 +834,16 @@ function ScheduledTaskEditorDialog({ return; } const schedule = scheduleFromDraft(draft); + if ( + schedule.type === "webhook" && + schedule.signature && + (!schedule.signature.header || + (!schedule.signature.secret && + !(liveTask?.schedule.type === "webhook" && liveTask.webhook?.hasSecret))) + ) { + reportFailure("Signing secret is required", "Enter the signature header and secret."); + return; + } if ( schedule.type === "interval" && (!Number.isSafeInteger(schedule.everyMs) || schedule.everyMs < MIN_SCHEDULED_TASK_INTERVAL_MS) @@ -643,7 +913,8 @@ function ScheduledTaskEditorDialog({ {draft.editingId ? "Edit task" : "New task"} - Run a prompt automatically — on an interval or at a fixed time. + Run a prompt automatically — on an interval, at a fixed time, or when a webhook is + called. @@ -833,17 +1104,116 @@ function ScheduledTaskEditorDialog({ aria-label="Schedule type" value={[draft.scheduleMode]} onValueChange={(values) => { - const mode = values[0]; - if (mode === "fixed" || mode === "interval") - setDraft((current) => ({ ...current, scheduleMode: mode })); + const mode = values[0] as ScheduleMode | undefined; + if (mode === "fixed" || mode === "interval" || mode === "webhook") + setDraft((current) => ({ + ...current, + scheduleMode: mode, + prompt: + mode === "webhook" && !current.prompt.trim() + ? DEFAULT_WEBHOOK_PROMPT + : current.prompt, + })); }} > At a time Every interval + On webhook - {draft.scheduleMode === "fixed" ? ( + {draft.scheduleMode === "webhook" ? ( +
+ +

+ { + "Each request runs the prompt. Use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}} or {{request}} in the prompt; only what it names reaches the agent." + } +

+
+
+ +

+ Reject requests without a valid HMAC-SHA256 signature of the body. +

+
+ + setDraft((current) => ({ ...current, signatureEnabled })) + } + /> +
+ {draft.signatureEnabled ? ( +
+ + + setDraft((current) => ({ + ...current, + signatureHeader: event.target.value, + })) + } + /> + + + + setDraft((current) => ({ + ...current, + signaturePrefix: event.target.value, + })) + } + /> + + + + + + + setDraft((current) => ({ + ...current, + signatureSecret: event.target.value, + })) + } + /> + +
+ ) : null} +
+ ) : draft.scheduleMode === "fixed" ? (
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 51b5576de607..e36716fa5deb 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -19,7 +19,9 @@ import { deriveProviderInstanceEntries } from "../../providerInstances"; import { scheduledTaskDefaultModel, matchesScheduledTaskScope, + scheduleFromDraft, taskToDraft, + webhookUrl, } from "./scheduledTasksSettings.logic"; const laptopId = EnvironmentId.make("laptop"); @@ -168,6 +170,44 @@ describe("editing scheduled task branch settings", () => { }); }); +describe("webhook scheduled tasks", () => { + const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const; + const webhookTask: ScheduledTask = { + ...legacyTask, + schedule: { type: "webhook", signature }, + webhook: { path: "/api/hooks/legacy-task/token", url: null, hasSecret: true }, + }; + + it("keeps a stored secret when the secret field is left blank", () => { + const draft = taskToDraft(webhookTask); + expect(draft.scheduleMode).toBe("webhook"); + expect(draft.signatureSecret).toBe(""); + expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature }); + expect(scheduleFromDraft({ ...draft, signatureSecret: " new " })).toEqual({ + type: "webhook", + signature: { ...signature, secret: "new" }, + }); + }); + + it("drops the signature when it is switched off and offers GitHub's settings", () => { + const draft = taskToDraft({ ...webhookTask, schedule: { type: "webhook", signature: null } }); + expect(draft.signatureEnabled).toBe(false); + expect(draft.signatureHeader).toBe("x-hub-signature-256"); + expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null }); + }); + + it("prefers the Connect URL and otherwise resolves the path on the environment", () => { + const endpoint = { path: "/api/hooks/id/token", url: null, hasSecret: false }; + expect(webhookUrl({ ...endpoint, url: "https://relay/v1/hooks/e/id/token" }, null)).toBe( + "https://relay/v1/hooks/e/id/token", + ); + expect(webhookUrl(endpoint, "https://box.tailnet.ts.net:3773/")).toBe( + "https://box.tailnet.ts.net:3773/api/hooks/id/token", + ); + expect(webhookUrl(endpoint, null)).toBe("/api/hooks/id/token"); + }); +}); + describe("scheduled task model defaults", () => { const instanceId = ProviderInstanceId.make("codex"); const projectId = ProjectId.make("project"); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index fcdb186ed8d8..526d2f8d56ce 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -3,6 +3,8 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type ScheduledTaskUpsertSchedule, + type ScheduledTaskWebhookEndpoint, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -43,7 +45,7 @@ export function validateScheduledTasksSearch(raw: Record) { }; } -type ScheduleMode = "fixed" | "interval"; +export type ScheduleMode = "fixed" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; export interface DraftState { @@ -71,6 +73,58 @@ export interface DraftState { * (reasoning, temperature, …) when the model itself is left unchanged. */ readonly baseModelSelection: ModelSelection | null; + readonly signatureEnabled: boolean; + readonly signatureHeader: string; + readonly signatureEncoding: "hex" | "base64"; + readonly signaturePrefix: string; + /** Write-only: empty keeps the secret already stored on the server. */ + readonly signatureSecret: string; +} + +/** GitHub's signature settings, the most common sender. */ +export const WEBHOOK_SIGNATURE_DEFAULTS = { + signatureHeader: "x-hub-signature-256", + signatureEncoding: "hex", + signaturePrefix: "sha256=", +} as const; + +/** 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 scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule { + if (draft.scheduleMode === "webhook") { + const secret = draft.signatureSecret.trim(); + return { + type: "webhook", + signature: draft.signatureEnabled + ? { + header: draft.signatureHeader.trim(), + encoding: draft.signatureEncoding, + prefix: draft.signaturePrefix, + ...(secret ? { secret } : {}), + } + : null, + }; + } + if (draft.scheduleMode === "interval") { + const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000); + return { type: "interval", everyMs }; + } + const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7; + return { + type: "fixed_time", + timeOfDay: draft.timeOfDay || "09:00", + ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }), + }; +} + +/** The address a sender should call: the public Connect URL, else the path on this environment's address. */ +export function webhookUrl( + endpoint: ScheduledTaskWebhookEndpoint, + httpBaseUrl: string | null, +): string { + if (endpoint.url !== null) return endpoint.url; + return httpBaseUrl ? new URL(endpoint.path, httpBaseUrl).href : endpoint.path; } export function taskToDraft(task: ScheduledTask): DraftState { @@ -84,7 +138,8 @@ export function taskToDraft(task: ScheduledTask): DraftState { title: task.title, prompt: task.prompt, enabled: task.enabled, - scheduleMode: schedule.type === "interval" ? "interval" : "fixed", + scheduleMode: + schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed", intervalMinutes: schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15", timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00", @@ -105,6 +160,15 @@ export function taskToDraft(task: ScheduledTask): DraftState { runtimeMode: task.runtimeMode, interactionMode: task.interactionMode, baseModelSelection: task.modelSelection, + ...(schedule.type === "webhook" && schedule.signature !== null + ? { + signatureEnabled: true, + signatureHeader: schedule.signature.header, + signatureEncoding: schedule.signature.encoding, + signaturePrefix: schedule.signature.prefix, + } + : { signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS }), + signatureSecret: "", }; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index bb9af1ff358d..0d5b6689045c 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -63,6 +63,31 @@ environment's time zone, which may differ from your phone's. You can edit, pause, resume, run immediately, or delete a task from the list. Leaving an edited form asks before discarding unsaved changes. +## Webhook automations + +Choose **On webhook** as a task's schedule to run it whenever another service +calls its URL, such as GitHub on a new pull request or a CI job that failed. +After you save the task, copy its URL from the editor. If the environment is +linked to [T3 Connect](remote-access.md), the URL is public; otherwise it works +anywhere the environment itself is reachable. **Rotate** replaces the URL and +the old one stops working. + +The prompt decides what the agent sees. Placeholders pull values out of the +request: `{{body.path}}` for a JSON or form field, `{{headers.name}}`, +`{{query.name}}`, `{{body}}` for the raw body, and `{{request}}` for everything. +For example, `Review this PR: {{body.pull_request.html_url}}` sends only the +pull request link. A placeholder with no value is left empty. + +For GitHub, turn on **Require signature**, keep the header +`x-hub-signature-256`, hex encoding and the `sha256=` prefix, and enter the +same secret in the repository's webhook settings with content type +`application/json`. Requests without a valid signature are rejected. + +Pick **Deliveries** from a task's menu to see recent requests and the prompt +each one produced. If the environment is offline, the sender gets an error and +nothing runs; redeliver from the sender, such as GitHub's **Recent Deliveries**, +once it is back. + ## Defaults and inheritance General contains the model and workspace for new threads. Integrations controls agent browser diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 86f947894827..1e42b6826b3f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1076,6 +1076,14 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + scheduledTaskWebhookDeliveries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:scheduled-task:webhook-deliveries", + tag: WS_METHODS.scheduledTasksListWebhookDeliveries, + }), + scheduledTaskWebhookDelivery: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:scheduled-task:webhook-delivery", + tag: WS_METHODS.scheduledTasksGetWebhookDelivery, + }), /** Live scheduled-task list: snapshot on subscribe, fresh list after every server-side change. */ scheduledTasksLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:scheduled-tasks:live", @@ -1291,14 +1299,6 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), - listScheduledTaskWebhookDeliveries: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:scheduled-task:list-webhook-deliveries", - tag: WS_METHODS.scheduledTasksListWebhookDeliveries, - }), - getScheduledTaskWebhookDelivery: createEnvironmentRpcCommand(runtime, { - label: "environment-data:server:scheduled-task:get-webhook-delivery", - tag: WS_METHODS.scheduledTasksGetWebhookDelivery, - }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates, From 5218f11927311a6426c6b1a2b499abf6cb80645e Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:06:55 -0700 Subject: [PATCH 2/8] feat(web): webhook tasks are editable again Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/settings/ScheduledTasksSettings.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 65f4ef5c23a3..dc90845bbc85 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -332,7 +332,7 @@ function ScheduledTaskEnvironmentSection({ const linkedTask = tasks?.find((task) => task.id === taskId); const openedLink = useRef(false); useEffect(() => { - if (!openedLink.current && linkedTask && linkedTask.schedule.type !== "webhook") { + if (!openedLink.current && linkedTask) { openedLink.current = true; onEdit(environment.environmentId, linkedTask); } @@ -479,8 +479,7 @@ function ScheduledTaskRow({ - {/* Webhook tasks are not editable here yet; saving would drop their URL. */} - + Edit From c29ca8517df800c2d1f2e208dd845af57ff682ca Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:08:02 -0700 Subject: [PATCH 3/8] docs(user): webhook URLs need a managed tunnel to be public Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/user/project-settings.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 0d5b6689045c..2e1717c88127 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -67,9 +67,9 @@ Leaving an edited form asks before discarding unsaved changes. Choose **On webhook** as a task's schedule to run it whenever another service calls its URL, such as GitHub on a new pull request or a CI job that failed. -After you save the task, copy its URL from the editor. If the environment is -linked to [T3 Connect](remote-access.md), the URL is public; otherwise it works -anywhere the environment itself is reachable. **Rotate** replaces the URL and +After you save the task, copy its URL from the editor. If the environment uses a +[T3 Connect](remote-access.md) managed tunnel, the URL is public; otherwise it +works anywhere the environment itself is reachable. **Rotate** replaces the URL and the old one stops working. The prompt decides what the agent sees. Placeholders pull values out of the @@ -83,8 +83,8 @@ For GitHub, turn on **Require signature**, keep the header same secret in the repository's webhook settings with content type `application/json`. Requests without a valid signature are rejected. -Pick **Deliveries** from a task's menu to see recent requests and the prompt -each one produced. If the environment is offline, the sender gets an error and +On desktop and web, pick **Deliveries** from a task's menu to see recent +requests and the prompt each one produced. If the environment is offline, the sender gets an error and nothing runs; redeliver from the sender, such as GitHub's **Recent Deliveries**, once it is back. From 554ec9e8a96918b11f49306cb10718cd25c0edcf Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:51:24 -0700 Subject: [PATCH 4/8] feat(web): webhook task deep links open the editor again Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/settings/ScheduledTasksSettings.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index dc90845bbc85..7cfcdf95d63b 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -366,12 +366,6 @@ function ScheduledTaskEnvironmentSection({ description="This task no longer exists or is outside the selected project scope." role="status" /> - ) : linkedTask?.schedule.type === "webhook" ? ( - ) : null} {tasks.length === 0 ? ( Date: Sat, 3 Oct 2026 07:55:49 -0700 Subject: [PATCH 5/8] fix(web): rotate always confirms and a missing delivery shows its error Also name the Settings path for webhook automations in the user docs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/settings/ScheduledTasksSettings.tsx | 7 +++++-- docs/user/project-settings.md | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 7cfcdf95d63b..58fe6ca1a6d2 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -549,7 +549,7 @@ function WebhookDeliveriesDialog({ {error ?

{error}

: null} - {selectedId !== null && selected === null ? ( + {selectedId !== null && selected === null && selectedQuery.error === null ? (

Loading delivery…

@@ -676,7 +676,10 @@ function WebhookEndpointField({ const confirmed = (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", { variant: "destructive", - })) ?? true; + })) ?? + // No themed dialog host is mounted; fall back to the native prompt + // rather than rotating unasked. + window.confirm("Rotate this webhook URL? The current URL stops working."); if (!confirmed) return; setRotating(true); const result = await rotate({ environmentId, input: { id: task.id } }); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 2e1717c88127..bf30f0ba637a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -65,7 +65,7 @@ Leaving an edited form asks before discarding unsaved changes. ## Webhook automations -Choose **On webhook** as a task's schedule to run it whenever another service +In **Settings → Scheduled tasks**, choose **On webhook** as a task's schedule to run it whenever another service calls its URL, such as GitHub on a new pull request or a CI job that failed. After you save the task, copy its URL from the editor. If the environment uses a [T3 Connect](remote-access.md) managed tunnel, the URL is public; otherwise it From 178d8ed819667c6b45cb460d61b373585183e053 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:21:34 -0700 Subject: [PATCH 6/8] fix(web): a failed delivery detail shows only its error, not the list Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/settings/ScheduledTasksSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 58fe6ca1a6d2..e1bb5c7dfdcd 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -587,7 +587,7 @@ function WebhookDeliveriesDialog({ {selected.body || "(empty)"}
- ) : deliveries === null ? ( + ) : selectedId !== null ? null : deliveries === null ? (

Loading deliveries…

From 6a35e11cf693736ec9536c43853430c62d4597a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:31:28 -0700 Subject: [PATCH 7/8] fix(web): a deleted task's URL is not shown, and only full URLs copy Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/components/settings/ScheduledTasksSettings.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index e1bb5c7dfdcd..953d82e1432b 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -707,6 +707,8 @@ function WebhookEndpointField({ size="sm" variant="outline" type="button" + // A bare path is not a URL a sender can call. + disabled={!url.startsWith("http")} onClick={() => copyToClipboard(url, undefined)} > @@ -782,7 +784,10 @@ function ScheduledTaskEditorDialog({ tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); // The live row, so a rotated URL shows up without reopening the dialog. - const liveTask = tasksQuery.data?.tasks.find((entry) => entry.id === draft.editingId) ?? task; + // Once the list has loaded, a missing task is gone; don't keep showing its URL. + const liveTask = tasksQuery.data + ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) + : task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); From b5914636b32e84aacc9aea9d96653b10b33d88aa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 5 Oct 2026 12:08:12 -0700 Subject: [PATCH 8/8] feat(web): webhook URLs say who can reach them, using the shared address logic Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/settings/ScheduledTasksSettings.tsx | 13 ++++--------- .../settings/scheduledTasksSettings.logic.test.ts | 12 ------------ .../settings/scheduledTasksSettings.logic.ts | 10 ---------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 953d82e1432b..884ae744c878 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -42,6 +42,7 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { requestConfirmDialog } from "../../confirmDialog"; +import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useEnvironment, @@ -62,7 +63,6 @@ import { scheduleFromDraft, scheduledTaskDefaultModel, taskToDraft, - webhookUrl, type DraftState, type ScheduleMode, type WorkspaceMode, @@ -671,7 +671,7 @@ function WebhookEndpointField({ if (!task || !endpoint) { return

The URL appears after you save.

; } - const url = webhookUrl(endpoint, httpBaseUrl); + const { address: url, copyable, note } = webhookAddress(endpoint, httpBaseUrl); const rotateUrl = async () => { const confirmed = (await requestConfirmDialog("Rotate this webhook URL?\nThe current URL stops working.", { @@ -708,7 +708,7 @@ function WebhookEndpointField({ variant="outline" type="button" // A bare path is not a URL a sender can call. - disabled={!url.startsWith("http")} + disabled={!copyable} onClick={() => copyToClipboard(url, undefined)} > @@ -724,12 +724,7 @@ function WebhookEndpointField({ Rotate
- {endpoint.url === null ? ( -

- Link this environment to T3 Connect for a public URL. This address works wherever the - environment is reachable. -

- ) : null} + {note !== null ?

{note}

: null} ); } diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index e36716fa5deb..9ccd9881d943 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -21,7 +21,6 @@ import { matchesScheduledTaskScope, scheduleFromDraft, taskToDraft, - webhookUrl, } from "./scheduledTasksSettings.logic"; const laptopId = EnvironmentId.make("laptop"); @@ -195,17 +194,6 @@ describe("webhook scheduled tasks", () => { expect(draft.signatureHeader).toBe("x-hub-signature-256"); expect(scheduleFromDraft(draft)).toEqual({ type: "webhook", signature: null }); }); - - it("prefers the Connect URL and otherwise resolves the path on the environment", () => { - const endpoint = { path: "/api/hooks/id/token", url: null, hasSecret: false }; - expect(webhookUrl({ ...endpoint, url: "https://relay/v1/hooks/e/id/token" }, null)).toBe( - "https://relay/v1/hooks/e/id/token", - ); - expect(webhookUrl(endpoint, "https://box.tailnet.ts.net:3773/")).toBe( - "https://box.tailnet.ts.net:3773/api/hooks/id/token", - ); - expect(webhookUrl(endpoint, null)).toBe("/api/hooks/id/token"); - }); }); describe("scheduled task model defaults", () => { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 526d2f8d56ce..c6d0cb28b940 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -4,7 +4,6 @@ import { ScheduledTaskId, type ScheduledTask, type ScheduledTaskUpsertSchedule, - type ScheduledTaskWebhookEndpoint, type ModelSelection, type RuntimeMode, type ProviderInteractionMode, @@ -118,15 +117,6 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul }; } -/** The address a sender should call: the public Connect URL, else the path on this environment's address. */ -export function webhookUrl( - endpoint: ScheduledTaskWebhookEndpoint, - httpBaseUrl: string | null, -): string { - if (endpoint.url !== null) return endpoint.url; - return httpBaseUrl ? new URL(endpoint.path, httpBaseUrl).href : endpoint.path; -} - export function taskToDraft(task: ScheduledTask): DraftState { const schedule = task.schedule; const weekdays =