From b252d7dde78e17caba53ec1ff012a4e883a0fd90 Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Tue, 18 Aug 2026 19:02:45 -0700 Subject: [PATCH 1/2] Add agent webhook management --- agent/package-lock.json | 4 +- agent/package.json | 2 +- agent/src/index.ts | 57 +++- cli/README.md | 16 + cli/package-lock.json | 4 +- cli/package.json | 2 +- cli/src/api.ts | 86 ++++- cli/src/commands.ts | 102 ++++++ cli/src/index.ts | 6 + cloudflare-workers/api-edge/src/index.ts | 4 + .../api-edge/src/managed_agents.test.ts | 77 +++++ .../api-edge/src/managed_agents.ts | 205 +++++++++++- create-start/package.json | 4 +- docs/agents/webhooks.mdx | 93 ++++++ docs/docs.json | 37 +-- web/src/components/app-shell-nav.ts | 5 + web/src/components/app-shell.test.ts | 1 + web/src/managed-agents/Detail.tsx | 13 + web/src/managed-agents/Webhooks.tsx | 313 ++++++++++++++++++ web/src/managed-agents/api.ts | 94 +++++- 20 files changed, 1073 insertions(+), 52 deletions(-) create mode 100644 docs/agents/webhooks.mdx create mode 100644 web/src/managed-agents/Webhooks.tsx diff --git a/agent/package-lock.json b/agent/package-lock.json index 909dacdd..9297feb4 100644 --- a/agent/package-lock.json +++ b/agent/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencomputer/agent", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencomputer/agent", - "version": "0.5.1", + "version": "0.5.2", "dependencies": { "croner": "^9.1.0" }, diff --git a/agent/package.json b/agent/package.json index 11fd14a8..5704769e 100644 --- a/agent/package.json +++ b/agent/package.json @@ -1,6 +1,6 @@ { "name": "@opencomputer/agent", - "version": "0.5.1", + "version": "0.5.2", "description": "Reactive agent authoring API for OpenComputer.", "type": "module", "main": "./dist/index.js", diff --git a/agent/src/index.ts b/agent/src/index.ts index 99155b41..cd4e9b30 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -25,16 +25,28 @@ export interface ScheduleRunContext { readonly manual: boolean; } +export interface WebhookRequestContext { + readonly id: string; + readonly requestId: string; + readonly receivedAt: string; +} + interface BasicAgentInput { readonly text?: string; readonly payload?: DataValue; } export type AgentInput = - | (BasicAgentInput & { readonly source: Exclude }) + | (BasicAgentInput & { + readonly source: Exclude; + }) | (BasicAgentInput & { readonly source: "schedule"; readonly schedule: Readonly; + }) + | (BasicAgentInput & { + readonly source: "webhook"; + readonly webhook: Readonly; }); export interface ResourceReference { @@ -306,7 +318,10 @@ export function defineConnection(input: { } const redirectOrigins = input.redirectOrigins?.map((input) => { const redirectOrigin = new URL(input.origin); - if (redirectOrigin.protocol !== "https:" || redirectOrigin.pathname !== "/") { + if ( + redirectOrigin.protocol !== "https:" || + redirectOrigin.pathname !== "/" + ) { throw new Error( "Connection redirect origins must be HTTPS origins without a path", ); @@ -442,7 +457,9 @@ function schedulePayload(value: DataValue | undefined): DataValue | undefined { throw new Error("Schedule payloads must be JSON-compatible"); } if (serialized === undefined || serialized.length > 32 * 1024) { - throw new Error("Schedule payloads must be JSON-compatible and at most 32 KiB"); + throw new Error( + "Schedule payloads must be JSON-compatible and at most 32 KiB", + ); } return JSON.parse(serialized) as DataValue; } @@ -461,7 +478,9 @@ export function defineSchedule(input: { const id = resourceIdentifier(input.id, "defineSchedule"); const cron = input.cron.trim().replace(/\s+/g, " "); if (cron.split(" ").length !== 5) { - throw new Error("Schedule cron expressions must contain exactly five fields"); + throw new Error( + "Schedule cron expressions must contain exactly five fields", + ); } const timezone = input.timezone?.trim() || "UTC"; try { @@ -474,7 +493,9 @@ export function defineSchedule(input: { } catch { throw new Error(`Schedule ${id} has an invalid cron expression`); } - const enabled = [...new Set(input.enabled ?? ["production"])] as ScheduleEnvironment[]; + const enabled = [ + ...new Set(input.enabled ?? ["production"]), + ] as ScheduleEnvironment[]; if ( !enabled.length || enabled.some( @@ -517,7 +538,10 @@ export function defineChannel(input: { }): SlackChannelDefinition { const id = resourceIdentifier(input.id, "defineChannel"); const scopes = [...new Set(input.scopes.bot.map((scope) => scope.trim()))]; - if (!scopes.length || scopes.some((scope) => !SLACK_SCOPE_PATTERN.test(scope))) { + if ( + !scopes.length || + scopes.some((scope) => !SLACK_SCOPE_PATTERN.test(scope)) + ) { throw new Error("Slack bot scopes must be non-empty Slack scope names"); } const events = [...new Set(input.events ?? [])]; @@ -527,16 +551,23 @@ export function defineChannel(input: { throw new Error(`Slack event ${event} requires bot scope ${required}`); } } - const destinations: Record> = {}; + const destinations: Record< + string, + Readonly + > = {}; for (const [name, destination] of Object.entries(input.destinations ?? {})) { const destinationId = resourceIdentifier(name, "Channel destination"); const required = destination.visibility === "private" ? "groups:read" : "channels:read"; if (!scopes.includes(required)) { - throw new Error(`Slack destination ${destinationId} requires bot scope ${required}`); + throw new Error( + `Slack destination ${destinationId} requires bot scope ${required}`, + ); } if (!scopes.includes("chat:write")) { - throw new Error(`Slack destination ${destinationId} requires bot scope chat:write`); + throw new Error( + `Slack destination ${destinationId} requires bot scope chat:write`, + ); } destinations[destinationId] = Object.freeze({ ...destination }); } @@ -545,11 +576,15 @@ export function defineChannel(input: { version: 1 as const, id, type: input.type, - ...(input.displayName?.trim() ? { displayName: input.displayName.trim() } : {}), + ...(input.displayName?.trim() + ? { displayName: input.displayName.trim() } + : {}), scopes: Object.freeze({ bot: Object.freeze(scopes) }), events: Object.freeze(events), destinations: Object.freeze(destinations), - routing: Object.freeze({ whenAmbiguous: input.routing?.whenAmbiguous ?? "ask" }), + routing: Object.freeze({ + whenAmbiguous: input.routing?.whenAmbiguous ?? "ask", + }), }); } diff --git a/cli/README.md b/cli/README.md index a9e661d8..e73dbf38 100644 --- a/cli/README.md +++ b/cli/README.md @@ -44,6 +44,22 @@ opencomputer run hello-world "Say hello" The CLI calls the public OpenComputer API and uses OpenComputer authentication. It does not require a separate backend account, key, or CLI. +## Agent webhooks + +Create an environment-scoped webhook that starts a fresh session for the +selected agent. The bearer token is displayed only when created or rotated: + +```bash +opencomputer webhooks create daily-hygiene --agent current --environment production +opencomputer webhooks list --agent current --environment production +opencomputer webhooks disable +opencomputer webhooks rotate-token +opencomputer webhooks remove +``` + +Invoke the URL with a JSON object containing `text`, `payload`, or both. The +structured payload is available to agent code as `input.payload`. + ## Secrets and managed egress Secret values are read from a hidden prompt, or from standard input in CI. diff --git a/cli/package-lock.json b/cli/package-lock.json index 535648a7..934693e4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencomputer/cli", - "version": "0.5.5", + "version": "0.5.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencomputer/cli", - "version": "0.5.5", + "version": "0.5.6", "dependencies": { "@opencode-ai/sdk": "1.18.4", "ai": "^7.0.45", diff --git a/cli/package.json b/cli/package.json index 5eb04c3d..78080bcd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opencomputer/cli", - "version": "0.5.5", + "version": "0.5.6", "description": "Build, test, deploy, and share OpenComputer agents as code.", "type": "module", "bin": { diff --git a/cli/src/api.ts b/cli/src/api.ts index 0e00d9ab..c6458528 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -66,6 +66,20 @@ export interface AgentRuntimeVariableMetadata { updatedAt: string; } +export interface ManagedAgentWebhook { + id: string; + projectId: string; + environment: "development" | "production"; + agentId: string; + name: string; + enabled: boolean; + invocationUrl: string; + token?: string; + createdAt: string; + updatedAt: string; + lastInvokedAt?: string; +} + export interface ManagedAgentLog { id: string; cursor: string; @@ -322,6 +336,72 @@ export class OpenComputerClient { ); } + async webhooks(input: { + projectId: string; + environment?: "development" | "production"; + agentId?: string; + }): Promise { + const query = new URLSearchParams(); + if (input.environment) query.set("environment", input.environment); + if (input.agentId) query.set("agentId", input.agentId); + const suffix = query.size ? `?${query.toString()}` : ""; + const result = await this.request<{ webhooks: ManagedAgentWebhook[] }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks${suffix}`, + ); + return result.webhooks; + } + + createWebhook(input: { + projectId: string; + name: string; + environment: "development" | "production"; + agentId: string; + }) { + return this.request<{ webhook: ManagedAgentWebhook }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks`, + { + method: "POST", + body: JSON.stringify({ + name: input.name, + environment: input.environment, + agentId: input.agentId, + }), + }, + ).then((result) => result.webhook); + } + + updateWebhook(input: { + projectId: string; + webhookId: string; + name?: string; + enabled?: boolean; + }) { + return this.request<{ webhook: ManagedAgentWebhook }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}`, + { + method: "PATCH", + body: JSON.stringify({ + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + }), + }, + ).then((result) => result.webhook); + } + + rotateWebhookToken(input: { projectId: string; webhookId: string }) { + return this.request<{ webhook: ManagedAgentWebhook }>( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}/rotate-token`, + { method: "POST" }, + ).then((result) => result.webhook); + } + + deleteWebhook(input: { projectId: string; webhookId: string }) { + return this.request( + `/api/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}`, + { method: "DELETE" }, + ); + } + logs(input: { agentId?: string; sessionId?: string; @@ -367,7 +447,11 @@ export class OpenComputerClient { id: string; digest: string; localAgentId: string; - agents: Array<{ localId: string; agentId: string; artifactDigest: string }>; + agents: Array<{ + localId: string; + agentId: string; + artifactDigest: string; + }>; resources: ProjectResourceManifest; }; source: { diff --git a/cli/src/commands.ts b/cli/src/commands.ts index c65f55d4..980274dd 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -798,6 +798,108 @@ export async function runCommand( throw new Error("Use `opencomputer env set|list|remove `."); } + if (command === "webhooks") { + const action = args.shift(); + const projectReference = option(args, "--project"); + const agentOption = option(args, "--agent"); + const environment = environmentOption(option(args, "--environment")); + const project = await selectedProject( + client, + config, + projectReference, + !globals.json, + ); + const agentId = await selectedSessionAgent( + client, + project, + !agentOption || agentOption === "current" ? undefined : agentOption, + ); + if (action === "list") { + if (args.length) throw new Error(`Unexpected argument: ${args[0]}`); + const webhooks = await client.webhooks({ + projectId: project.projectId, + environment, + agentId, + }); + if (globals.json) printJSON(webhooks); + else if (!webhooks.length) process.stdout.write("No webhooks.\n"); + else { + for (const webhook of webhooks) { + process.stdout.write( + `${webhook.id} ${webhook.enabled ? "enabled " : "disabled"} ` + + `${webhook.name} ${webhook.invocationUrl}\n`, + ); + } + } + return; + } + if (action === "create") { + const name = args.shift()?.trim(); + if (!name || args.length) { + throw new Error("Use `opencomputer webhooks create `."); + } + const webhook = await client.createWebhook({ + projectId: project.projectId, + name, + environment, + agentId, + }); + if (globals.json) printJSON(webhook); + else { + process.stdout.write( + `Created ${webhook.name} (${webhook.id}) for ${agentId}@${environment}.\n` + + `URL: ${webhook.invocationUrl}\n` + + `Token: ${webhook.token ?? "unavailable"}\n` + + "Save this token now. It will not be shown again.\n", + ); + } + return; + } + const webhookId = args.shift(); + if (!webhookId || args.length) { + throw new Error( + "Use `opencomputer webhooks list|create|enable|disable|rotate-token|remove`.", + ); + } + if (action === "enable" || action === "disable") { + const webhook = await client.updateWebhook({ + projectId: project.projectId, + webhookId, + enabled: action === "enable", + }); + if (globals.json) printJSON(webhook); + else + process.stdout.write( + `${action === "enable" ? "Enabled" : "Disabled"} ${webhook.name}.\n`, + ); + return; + } + if (action === "rotate-token") { + const webhook = await client.rotateWebhookToken({ + projectId: project.projectId, + webhookId, + }); + if (globals.json) printJSON(webhook); + else { + process.stdout.write( + `Rotated the token for ${webhook.name}.\n` + + `Token: ${webhook.token ?? "unavailable"}\n` + + "Save this token now. The previous token no longer works.\n", + ); + } + return; + } + if (action === "remove" || action === "delete") { + await client.deleteWebhook({ projectId: project.projectId, webhookId }); + if (globals.json) printJSON({ deleted: true, webhookId }); + else process.stdout.write(`Removed webhook ${webhookId}.\n`); + return; + } + throw new Error( + "Use `opencomputer webhooks list|create|enable|disable|rotate-token|remove`.", + ); + } + if (command === "logs") { const follow = flag(args, "--follow"); let agentId = option(args, "--agent"); diff --git a/cli/src/index.ts b/cli/src/index.ts index 0f64b5b9..465cd5d8 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -63,6 +63,12 @@ Usage: opencomputer env set [--environment development|production] [--agent |current] opencomputer env list [--environment development|production] [--agent |current] opencomputer env remove [--environment development|production] [--agent |current] + opencomputer webhooks list [--environment development|production] [--agent |current] + opencomputer webhooks create [--environment development|production] [--agent |current] + opencomputer webhooks enable [--project ] + opencomputer webhooks disable [--project ] + opencomputer webhooks rotate-token [--project ] + opencomputer webhooks remove [--project ] opencomputer logs [--agent ] [--session ] [--environment development|production] [--follow] opencomputer deploy [--alias ] opencomputer run [--keep] diff --git a/cloudflare-workers/api-edge/src/index.ts b/cloudflare-workers/api-edge/src/index.ts index 4adc1965..7e575070 100644 --- a/cloudflare-workers/api-edge/src/index.ts +++ b/cloudflare-workers/api-edge/src/index.ts @@ -50,6 +50,7 @@ import * as templates from "./templates"; import * as webhooks from "./webhooks"; import { createAPIKey, hashAPIKey } from "./api_keys"; import { + handleAgentWebhookInvocation, handleManagedAgentChannelConnection, proxyManagedAgents, } from "./managed_agents"; @@ -3615,6 +3616,9 @@ export default { if (path.startsWith("/api/managed-agents/channel-connections/")) { return handleManagedAgentChannelConnection(req, env); } + if (path.startsWith("/api/agent-webhooks/")) { + return handleAgentWebhookInvocation(req, env); + } if ( path === "/api/managed-agents" || path.startsWith("/api/managed-agents/") diff --git a/cloudflare-workers/api-edge/src/managed_agents.test.ts b/cloudflare-workers/api-edge/src/managed_agents.test.ts index da382f0a..6d59aa69 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.test.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + handleAgentWebhookInvocation, handleManagedAgentChannelConnection, mintManagedAgentsAssertion, proxyManagedAgents, @@ -35,6 +36,82 @@ describe("managed agents proxy", () => { expect(Number(payload.exp) - Number(payload.iat)).toBe(120); }); + it("forwards webhook text and payload without requiring a user API key", async () => { + const fetchSpy = vi.fn( + async (_target: URL | RequestInfo, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer webhook-secret", + ); + expect(new Headers(init?.headers).get("idempotency-key")).toBe( + "delivery-1", + ); + expect(await new Response(init?.body).json()).toEqual({ + text: "Run the review", + payload: { mode: "hygiene", repository: "acme/api" }, + }); + return Response.json({ + request: { + id: "whr_request", + webhookId: "wh_0123456789abcdef0123456789abcdef", + projectId: "prj_test", + environment: "development", + agentId: "reviewer", + sessionId: "session_test", + outcome: "accepted", + createdAt: "2026-08-18T00:00:00.000Z", + updatedAt: "2026-08-18T00:00:01.000Z", + internal: "private", + }, + }); + }, + ); + vi.stubGlobal("fetch", fetchSpy); + + const response = await handleAgentWebhookInvocation( + new Request( + "https://app.opencomputer.dev/api/agent-webhooks/wh_0123456789abcdef0123456789abcdef", + { + method: "POST", + headers: { + authorization: "Bearer webhook-secret", + "content-type": "application/json", + "idempotency-key": "delivery-1", + }, + body: JSON.stringify({ + text: "Run the review", + payload: { mode: "hygiene", repository: "acme/api" }, + }), + }, + ), + { MANAGED_AGENTS_API_URL: "https://managedagents.test" }, + ); + + expect(response.status).toBe(202); + expect(fetchSpy).toHaveBeenCalledOnce(); + const body = await response.json(); + expect(body).toMatchObject({ + request: { sessionId: "session_test", outcome: "accepted" }, + duplicate: false, + sessionUrl: + "https://app.opencomputer.dev/projects/prj_test/sessions/session_test?agent=reviewer&environment=development", + }); + expect(JSON.stringify(body)).not.toContain("internal"); + }); + + it("rejects webhook calls without bearer credentials", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const response = await handleAgentWebhookInvocation( + new Request( + "https://app.opencomputer.dev/api/agent-webhooks/wh_0123456789abcdef0123456789abcdef", + { method: "POST", body: "{}" }, + ), + {}, + ); + expect(response.status).toBe(401); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("does not consume a channel connection grant on link preview", async () => { const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); diff --git a/cloudflare-workers/api-edge/src/managed_agents.ts b/cloudflare-workers/api-edge/src/managed_agents.ts index 57af1b3b..d3892cf2 100644 --- a/cloudflare-workers/api-edge/src/managed_agents.ts +++ b/cloudflare-workers/api-edge/src/managed_agents.ts @@ -99,7 +99,9 @@ async function publicErrorResponse(upstream: Response): Promise { message = "The requested agent resource was not found."; } else if (upstream.status === 409) { if (backendCode === "destination_verification_failed") { - if (backendMessage === "Invite the Slack app to this conversation first") { + if ( + backendMessage === "Invite the Slack app to this conversation first" + ) { message = backendMessage; } else if (backendMessage === "Slack conversation is archived") { message = "That Slack conversation is archived."; @@ -334,6 +336,50 @@ function publicRuntimeVariable(value: unknown): Record { }; } +function publicWebhook( + value: unknown, + publicOrigin?: string, +): Record { + const webhook = record(value) ?? {}; + const id = typeof webhook.id === "string" ? webhook.id : ""; + return { + id: webhook.id, + projectId: webhook.projectId, + environment: webhook.environment, + agentId: webhook.agentId, + name: webhook.name, + enabled: webhook.enabled, + ...(publicOrigin && id + ? { + invocationUrl: `${publicOrigin}/api/agent-webhooks/${encodeURIComponent(id)}`, + } + : {}), + ...(typeof webhook.token === "string" ? { token: webhook.token } : {}), + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + lastInvokedAt: webhook.lastInvokedAt, + }; +} + +function publicWebhookRequest(value: unknown): Record { + const request = record(value) ?? {}; + return { + id: request.id, + webhookId: request.webhookId, + projectId: request.projectId, + environment: request.environment, + agentId: request.agentId, + deploymentId: request.deploymentId, + sessionId: request.sessionId, + outcome: request.outcome, + ...(request.error + ? { error: "The webhook request could not start a session." } + : {}), + createdAt: request.createdAt, + updatedAt: request.updatedAt, + }; +} + const PRIVATE_EVENT_KEYS = new Set([ "accountId", "account_id", @@ -375,6 +421,7 @@ function publicSuccessBody( method: string, suffix: string, value: unknown, + publicOrigin?: string, ): unknown { const body = record(value) ?? {}; if (method === "GET" && suffix === "/agents") { @@ -414,6 +461,29 @@ function publicSuccessBody( ) { return stripPrivateValues(body); } + if (method === "GET" && /^\/projects\/[^/]+\/webhooks$/.test(suffix)) { + return { + webhooks: Array.isArray(body.webhooks) + ? body.webhooks.map((value) => publicWebhook(value, publicOrigin)) + : [], + }; + } + if ( + (method === "POST" || method === "PATCH") && + /^\/projects\/[^/]+\/webhooks(?:\/[^/]+(?:\/rotate-token)?)?$/.test(suffix) + ) { + return { webhook: publicWebhook(body.webhook, publicOrigin) }; + } + if ( + method === "GET" && + /^\/projects\/[^/]+\/webhooks\/[^/]+\/requests$/.test(suffix) + ) { + return { + requests: Array.isArray(body.requests) + ? body.requests.map(publicWebhookRequest) + : [], + }; + } if ( (method === "GET" || method === "PUT") && /^\/projects\/[^/]+\/runtime-variables(?:\/[^/]+)?$/.test(suffix) @@ -595,13 +665,15 @@ async function publicSuccessResponse( upstream: Response, method: string, suffix: string, + publicOrigin?: string, ): Promise { const value: unknown = await upstream.json(); const headers = new Headers({ "content-type": "application/json" }); const cacheControl = upstream.headers.get("cache-control"); if (cacheControl) headers.set("cache-control", cacheControl); + if (suffix.includes("/webhooks")) headers.set("cache-control", "no-store"); return new Response( - JSON.stringify(publicSuccessBody(method, suffix, value)), + JSON.stringify(publicSuccessBody(method, suffix, value, publicOrigin)), { status: upstream.status, headers, @@ -736,6 +808,26 @@ function isAllowedManagedAgentsRoute(method: string, suffix: string): boolean { ) { return true; } + if ( + (method === "GET" || method === "POST") && + /^\/projects\/[^/]+\/webhooks$/.test(suffix) + ) { + return true; + } + if ( + (method === "PATCH" || method === "DELETE") && + /^\/projects\/[^/]+\/webhooks\/[^/]+$/.test(suffix) + ) { + return true; + } + if ( + (method === "POST" && + /^\/projects\/[^/]+\/webhooks\/[^/]+\/rotate-token$/.test(suffix)) || + (method === "GET" && + /^\/projects\/[^/]+\/webhooks\/[^/]+\/requests$/.test(suffix)) + ) { + return true; + } if ( (method === "GET" || method === "PUT" || method === "DELETE") && /^\/projects\/[^/]+\/runtime-variables(?:\/[^/]+)?$/.test(suffix) @@ -753,7 +845,8 @@ function isAllowedManagedAgentsRoute(method: string, suffix: string): boolean { if (method === "GET" && suffix === "/outboxes") return true; if (method === "GET" && suffix === "/schedules") return true; if (method === "GET" && suffix === "/schedule-runs") return true; - if (method === "POST" && /^\/schedules\/[^/]+\/run$/.test(suffix)) return true; + if (method === "POST" && /^\/schedules\/[^/]+\/run$/.test(suffix)) + return true; if ( (method === "GET" && (/^\/connections(?:\/.*)?$/.test(suffix) || @@ -900,6 +993,111 @@ export async function handleManagedAgentChannelConnection( }); } +export async function handleAgentWebhookInvocation( + request: Request, + env: ManagedAgentsEnv, +): Promise { + const url = new URL(request.url); + const match = url.pathname.match(/^\/api\/agent-webhooks\/([^/]+)$/); + if (!match?.[1] || request.method !== "POST") { + return Response.json( + { error: { code: "not_found", message: "Webhook not found." } }, + { status: 404 }, + ); + } + const webhookId = match[1]; + if (!/^wh_[a-f0-9]{32}$/.test(webhookId)) { + return Response.json( + { error: { code: "not_found", message: "Webhook not found." } }, + { status: 404 }, + ); + } + const authorization = request.headers.get("authorization"); + if (!authorization?.startsWith("Bearer ")) { + return Response.json( + { + error: { + code: "unauthorized", + message: "Webhook credentials are required.", + }, + }, + { status: 401 }, + ); + } + const base = ( + env.MANAGED_AGENTS_API_URL ?? DEFAULT_MANAGED_AGENTS_API_URL + ).replace(/\/+$/, ""); + const target = new URL( + `${base}/v1/agent-webhooks/${encodeURIComponent(webhookId)}`, + ); + if (target.protocol !== "https:" && target.hostname !== "localhost") { + return Response.json( + { + error: { + code: "unavailable", + message: "Webhook service is unavailable.", + }, + }, + { status: 503 }, + ); + } + const headers = new Headers({ + authorization, + "content-type": request.headers.get("content-type") ?? "application/json", + "x-request-id": crypto.randomUUID(), + }); + const idempotencyKey = request.headers.get("idempotency-key"); + if (idempotencyKey) headers.set("idempotency-key", idempotencyKey); + try { + const upstream = await fetch(target, { + method: "POST", + headers, + body: request.body, + redirect: "manual", + }); + if (!upstream.ok) return publicErrorResponse(upstream); + const body = record(await upstream.json()) ?? {}; + const webhookRequest = publicWebhookRequest(body.request); + const projectId = webhookRequest.projectId; + const agentId = webhookRequest.agentId; + const environment = webhookRequest.environment; + const sessionId = webhookRequest.sessionId; + const sessionUrl = + typeof projectId === "string" && + typeof agentId === "string" && + typeof environment === "string" && + typeof sessionId === "string" + ? `${url.origin}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}?agent=${encodeURIComponent(agentId)}&environment=${encodeURIComponent(environment)}` + : undefined; + return Response.json( + { + request: webhookRequest, + duplicate: body.duplicate === true, + ...(sessionUrl ? { sessionUrl } : {}), + }, + { status: 202 }, + ); + } catch (error) { + console.error( + JSON.stringify({ + level: "error", + event: "agent_webhook.upstream_failed", + webhookId, + message: error instanceof Error ? error.message : String(error), + }), + ); + return Response.json( + { + error: { + code: "unavailable", + message: "Webhook service is unavailable.", + }, + }, + { status: 502 }, + ); + } +} + export async function proxyManagedAgents( request: Request, env: ManagedAgentsEnv, @@ -980,6 +1178,7 @@ export async function proxyManagedAgents( upstream, request.method.toUpperCase(), suffix, + requestURL.origin, ); } catch (error) { console.error( diff --git a/create-start/package.json b/create-start/package.json index baeafb99..0187145f 100644 --- a/create-start/package.json +++ b/create-start/package.json @@ -1,6 +1,6 @@ { "name": "@opencomputer/create-start", - "version": "0.5.5", + "version": "0.5.6", "description": "Create a hello-world OpenComputer agent application.", "type": "module", "bin": { @@ -18,7 +18,7 @@ "node": ">=22.0.0" }, "dependencies": { - "@opencomputer/cli": "0.5.5" + "@opencomputer/cli": "0.5.6" }, "publishConfig": { "access": "public" diff --git a/docs/agents/webhooks.mdx b/docs/agents/webhooks.mdx new file mode 100644 index 00000000..6f969402 --- /dev/null +++ b/docs/agents/webhooks.mdx @@ -0,0 +1,93 @@ +--- +title: "Agent webhooks" +description: "Start an agent session from an external service" +--- + +Agent webhooks are stable, authenticated ingress points for an agent. Each +webhook targets one project agent and one environment. Calling it starts a +fresh durable session against the deployment active in that environment. + +Webhooks are operational configuration, so create them in the dashboard or +CLI rather than in agent source. Advancing a deployment does not change the +webhook URL. + +## Create a webhook + +Open an agent's **Webhooks** tab, select Development or Production, and choose +**Create webhook**. The dashboard shows the bearer token once. Store it in the +calling service's secret store. + +The CLI supports the same lifecycle: + +```bash +opencomputer webhooks create daily-hygiene \ + --agent current \ + --environment production + +opencomputer webhooks list --agent current --environment production +opencomputer webhooks disable +opencomputer webhooks enable +opencomputer webhooks rotate-token +opencomputer webhooks remove +``` + +Rotation invalidates the previous token immediately. List output never +contains a token. + +## Invoke it + +Send `POST` with `application/json` and the token as a bearer credential: + +```bash +curl -X POST 'https://app.opencomputer.dev/api/agent-webhooks/wh_...' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: delivery-123' \ + -d '{ + "text": "Run the feature-flag hygiene review.", + "payload": { + "mode": "hygiene", + "repository": "acme/widgets" + } + }' +``` + +At least one of `text` or `payload` is required. `text` becomes the initial +turn prompt. `payload` remains structured JSON available to the agent. The +response is HTTP 202 with the request, session ID, and dashboard session URL; +the agent continues asynchronously. + +Use a unique `Idempotency-Key` for each upstream delivery. Retrying the same +body with the same key returns the original request and session instead of +starting another one. + +## Read webhook input + +Use `useInput()` as with other session sources: + +```tsx +import { useInput } from "@opencomputer/agent"; + +export default function Agent() { + const input = useInput(); + const payload = + input.payload && + typeof input.payload === "object" && + !Array.isArray(input.payload) + ? input.payload + : {}; + + if (payload.mode === "hygiene") { + return `Run the configured hygiene workflow for ${payload.repository}.`; + } + + return input.text ?? "Ask the caller what workflow to run."; +} +``` + +Use payload fields such as `mode` for business behavior. `input.source` is +`"webhook"` and `input.webhook` contains the webhook ID, request ID, and receive +time for provenance and correlation. + +Development and Production webhooks have separate URLs, tokens, and sessions. +Disable or remove a webhook when its caller should no longer start sessions. diff --git a/docs/docs.json b/docs/docs.json index 8f041c64..6640c51a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -42,6 +42,7 @@ "agents/reactive-agents", "agents/sessions", "agents/schedules", + "agents/webhooks", "agents/capabilities", "agents/channels", "agents/outboxes", @@ -64,16 +65,11 @@ }, { "group": "Test and operate", - "pages": [ - "agents/playground", - "agents/logs" - ] + "pages": ["agents/playground", "agents/logs"] }, { "group": "Examples", - "pages": [ - "agents/examples/gtm-engineer" - ] + "pages": ["agents/examples/gtm-engineer"] } ] }, @@ -82,11 +78,7 @@ "groups": [ { "group": "Getting Started", - "pages": [ - "introduction", - "quickstart", - "how-it-works" - ] + "pages": ["introduction", "quickstart", "how-it-works"] }, { "group": "Sandboxes", @@ -110,18 +102,14 @@ "group": "Usage", "tag": "Preview", "expanded": true, - "pages": [ - "sandboxes/usage" - ] + "pages": ["sandboxes/usage"] } ] }, { "group": "Browser Sessions", "tag": "Preview", - "pages": [ - "browser-sessions/overview" - ] + "pages": ["browser-sessions/overview"] }, { "group": "Reserved Capacity", @@ -165,9 +153,7 @@ "group": "Usage & Tags", "tag": "Preview", "expanded": true, - "pages": [ - "reference/typescript-sdk/usage" - ] + "pages": ["reference/typescript-sdk/usage"] }, "reference/typescript-sdk/exec", "reference/typescript-sdk/filesystem", @@ -339,16 +325,11 @@ }, { "group": "Resources", - "pages": [ - "troubleshooting" - ] + "pages": ["troubleshooting"] }, { "group": "Self-hosting", - "pages": [ - "self-hosting/overview", - "self-hosting/gcp-development" - ] + "pages": ["self-hosting/overview", "self-hosting/gcp-development"] } ] } diff --git a/web/src/components/app-shell-nav.ts b/web/src/components/app-shell-nav.ts index 9afc0f23..ae9177fc 100644 --- a/web/src/components/app-shell-nav.ts +++ b/web/src/components/app-shell-nav.ts @@ -76,6 +76,11 @@ export function managedAgentsNav(options: { label: 'Schedules', icon: CalendarClock, }, + { + to: `${projectPath}/webhooks`, + label: 'Webhooks', + icon: Webhook, + }, { to: `${projectPath}/secrets`, label: 'Secrets', diff --git a/web/src/components/app-shell.test.ts b/web/src/components/app-shell.test.ts index cdb899de..10e43a89 100644 --- a/web/src/components/app-shell.test.ts +++ b/web/src/components/app-shell.test.ts @@ -23,6 +23,7 @@ describe('managed agents navigation', () => { 'Channels', 'Outboxes', 'Schedules', + 'Webhooks', 'Secrets', 'Debug playground', ]) diff --git a/web/src/managed-agents/Detail.tsx b/web/src/managed-agents/Detail.tsx index 8be0d302..50834c59 100644 --- a/web/src/managed-agents/Detail.tsx +++ b/web/src/managed-agents/Detail.tsx @@ -66,6 +66,7 @@ import { ManagedProjectSecrets } from './Secrets' import { ManagedSlackWizard } from './SlackWizard' import { ManagedAgentOutboxes } from './Outboxes' import { ManagedAgentSchedules } from './Schedules' +import { ManagedAgentWebhooks } from './Webhooks' import { AgentMarkdown } from './AgentMarkdown' type DetailTab = @@ -75,6 +76,7 @@ type DetailTab = | 'channels' | 'outboxes' | 'schedules' + | 'webhooks' | 'secrets' function formatDate(value: string) { @@ -479,6 +481,7 @@ export default function ManagedAgentDetail({ 'channels', 'outboxes', 'schedules', + 'webhooks', 'secrets', ]) const activeTab = project @@ -647,6 +650,7 @@ export default function ManagedAgentDetail({ { id: 'channels', label: 'Channels' }, ...(project ? ([{ id: 'outboxes', label: 'Outboxes' }] as const) : []), ...(project ? ([{ id: 'schedules', label: 'Schedules' }] as const) : []), + ...(project ? ([{ id: 'webhooks', label: 'Webhooks' }] as const) : []), ...(project ? ([{ id: 'secrets', label: 'Secrets' }] as const) : []), ] @@ -1024,6 +1028,15 @@ export default function ManagedAgentDetail({ /> ) : null} + {activeTab === 'webhooks' && project && agent ? ( + + ) : null} + {activeTab === 'secrets' && project ? ( '}' \\\n+ -H 'Content-Type: application/json' \\\n+ -H 'Idempotency-Key: ' \\\n+ -d '{"text":"Run this workflow","payload":{"mode":"default"}}'` +} + +export function ManagedAgentWebhooks({ + projectId, + agentId, + environment, + deployed, +}: { + projectId: string + agentId: string + environment: 'development' | 'production' + deployed: boolean +}) { + const queryClient = useQueryClient() + const queryKey = ['managed-agent-webhooks', projectId, agentId, environment] + const [creating, setCreating] = useState(false) + const [name, setName] = useState('') + const [credentials, setCredentials] = useState() + const [removing, setRemoving] = useState() + + const webhooks = useQuery({ + queryKey, + queryFn: () => getManagedAgentWebhooks(projectId, agentId, environment), + enabled: deployed, + }) + const create = useMutation({ + mutationFn: () => + createManagedAgentWebhook({ + projectId, + agentId, + environment, + name: name.trim(), + }), + onSuccess: async (webhook) => { + setCreating(false) + setName('') + setCredentials(webhook) + await queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => notifyError("Couldn't create that webhook.", error), + }) + const update = useMutation({ + mutationFn: (webhook: ManagedAgentWebhook) => + updateManagedAgentWebhook({ + projectId, + webhookId: webhook.id, + enabled: !webhook.enabled, + }), + onSuccess: async (webhook) => { + notifySuccess(webhook.enabled ? 'Webhook enabled.' : 'Webhook disabled.') + await queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => notifyError("Couldn't update that webhook.", error), + }) + const rotate = useMutation({ + mutationFn: (webhook: ManagedAgentWebhook) => + rotateManagedAgentWebhookToken(projectId, webhook.id), + onSuccess: (webhook) => { + setCredentials(webhook) + notifySuccess( + 'Webhook token rotated.', + 'The previous token no longer works.', + ) + }, + onError: (error) => notifyError("Couldn't rotate that token.", error), + }) + const remove = useMutation({ + mutationFn: (webhook: ManagedAgentWebhook) => + deleteManagedAgentWebhook(projectId, webhook.id), + onSuccess: async () => { + setRemoving(undefined) + notifySuccess('Webhook removed.') + await queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => notifyError("Couldn't remove that webhook.", error), + }) + + if (!deployed) { + return ( + + + + ) + } + if (webhooks.isError) { + return ( + + + + ) + } + + const columns: Column[] = [ + { + key: 'webhook', + header: 'Webhook', + cell: (webhook) => ( +
+

{webhook.name}

+

+ {webhook.invocationUrl} +

+
+ ), + }, + { + key: 'last-invoked', + header: 'Last invoked', + cell: (webhook) => ( + + {formatDate(webhook.lastInvokedAt)} + + ), + }, + { + key: 'status', + header: 'Status', + cell: (webhook) => ( + + ), + }, + { + key: 'actions', + header: '', + align: 'right', + cell: (webhook) => ( +
+ + + +
+ ), + }, + ] + + return ( + <> + + +
+ Webhooks + + Start a fresh {environment} session from an external system. Each + webhook is fixed to this agent and environment. + +
+ +
+ + webhook.id} + loading={webhooks.isLoading} + empty={ + + } + /> + +
+ + + + + Create webhook + + Give this ingress point a name. Its bearer token is shown once. + + +
+ + setName(event.target.value)} + /> +
+ + + + +
+
+ + !open && setCredentials(undefined)} + > + + + Save this webhook token + + OpenComputer stores only its hash, so this token cannot be shown + again. Rotating it invalidates the previous token. + + + {credentials?.token ? ( +
+
+ + +
+
+ + +
+
+ ) : null} + + + +
+
+ + !open && setRemoving(undefined)} + title="Remove webhook?" + description="Its URL and token will stop working immediately." + confirmLabel="Remove webhook" + destructive + pending={remove.isPending} + onConfirm={() => removing && remove.mutate(removing)} + /> + + ) +} diff --git a/web/src/managed-agents/api.ts b/web/src/managed-agents/api.ts index 184258a2..e25ad8e1 100644 --- a/web/src/managed-agents/api.ts +++ b/web/src/managed-agents/api.ts @@ -251,6 +251,23 @@ const scheduleRunsResponseSchema = z.object({ }) const scheduleRunResponseSchema = z.object({ run: scheduleRunSchema }) +const webhookSchema = z.object({ + id: z.string(), + projectId: z.string(), + environment: z.enum(['development', 'production']), + agentId: z.string(), + name: z.string(), + enabled: z.boolean(), + invocationUrl: z.string().url(), + token: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), + lastInvokedAt: z.string().optional(), +}) + +const webhooksResponseSchema = z.object({ webhooks: z.array(webhookSchema) }) +const webhookResponseSchema = z.object({ webhook: webhookSchema }) + const slackManifestResponseSchema = z.object({ connection: channelSchema, manifest: z.record(z.string(), z.unknown()), @@ -308,7 +325,7 @@ const sessionSchema = z.object({ deploymentId: z.string(), status: z.string(), source: z - .enum(['api', 'channel', 'playground', 'schedule']) + .enum(['api', 'channel', 'playground', 'schedule', 'webhook']) .optional() .default('api'), microvmState: z.string().optional(), @@ -357,6 +374,7 @@ export type ManagedAgentOutbox = z.infer export type ManagedAgentOutboxItem = z.infer export type ManagedAgentSchedule = z.infer export type ManagedAgentScheduleRun = z.infer +export type ManagedAgentWebhook = z.infer export type ManagedSlackManifest = z.infer export type ManagedProjectSecret = z.infer @@ -603,6 +621,80 @@ export async function runManagedAgentSchedule( ).run } +export async function getManagedAgentWebhooks( + projectId: string, + agentId: string, + environment: 'development' | 'production', +) { + const query = new URLSearchParams({ agentId, environment }) + return ( + await apiFetch( + `/managed-agents/projects/${encodeURIComponent(projectId)}/webhooks?${query.toString()}`, + undefined, + webhooksResponseSchema, + ) + ).webhooks +} + +export async function createManagedAgentWebhook(input: { + projectId: string + agentId: string + environment: 'development' | 'production' + name: string +}) { + return ( + await apiFetch( + `/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks`, + { + method: 'POST', + body: JSON.stringify({ + agentId: input.agentId, + environment: input.environment, + name: input.name, + }), + }, + webhookResponseSchema, + ) + ).webhook +} + +export async function updateManagedAgentWebhook(input: { + projectId: string + webhookId: string + enabled: boolean +}) { + return ( + await apiFetch( + `/managed-agents/projects/${encodeURIComponent(input.projectId)}/webhooks/${encodeURIComponent(input.webhookId)}`, + { method: 'PATCH', body: JSON.stringify({ enabled: input.enabled }) }, + webhookResponseSchema, + ) + ).webhook +} + +export async function rotateManagedAgentWebhookToken( + projectId: string, + webhookId: string, +) { + return ( + await apiFetch( + `/managed-agents/projects/${encodeURIComponent(projectId)}/webhooks/${encodeURIComponent(webhookId)}/rotate-token`, + { method: 'POST' }, + webhookResponseSchema, + ) + ).webhook +} + +export async function deleteManagedAgentWebhook( + projectId: string, + webhookId: string, +) { + return apiFetch( + `/managed-agents/projects/${encodeURIComponent(projectId)}/webhooks/${encodeURIComponent(webhookId)}`, + { method: 'DELETE' }, + ) +} + export async function startManagedAgentSlack( agentId: string, name: string, From 3c61f719ed54d16c4533de859dff67ea6b0343d9 Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Tue, 18 Aug 2026 19:17:13 -0700 Subject: [PATCH 2/2] Show agent selection on webhook pages --- web/src/managed-agents/Detail.tsx | 9 +++++---- web/src/managed-agents/Webhooks.tsx | 13 +++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/web/src/managed-agents/Detail.tsx b/web/src/managed-agents/Detail.tsx index 50834c59..745fd63d 100644 --- a/web/src/managed-agents/Detail.tsx +++ b/web/src/managed-agents/Detail.tsx @@ -684,17 +684,17 @@ export default function ManagedAgentDetail({ className={activeTab === 'playground' ? 'mb-0 shrink-0' : undefined} /> - {project && activeTab === 'playground' ? ( + {project ? (