From f083ad646c556d4f702d5362f9561aedeb66d9b1 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 14 Aug 2026 17:22:11 +0200 Subject: [PATCH 1/3] feat(deploy): print the app-trigger URL after a cloud deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent can be woken from outside by POSTing to its deployment trigger endpoint — that is how a customer's own product hands it work. But the URL was advertised nowhere the deploying user looks, so wiring an app to an agent started with someone being told the shape by hand. Deploy is the moment someone is actually wiring the agent up, so it is the moment the URL is worth knowing. Cloud deploys now print it, with the auth it needs and what the body does. Uses the persona id (the deployed NAME) rather than `handle.id`: names are unique per workspace among live agents and survive a redeploy, while the uuid does not, so the name is what an app should hard-code. Cloud mode only, and only when the cloud URL and workspace are both known. Co-Authored-By: Claude Opus 5 --- packages/deploy/src/deploy.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index ebd5f36e..8843e125 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -376,6 +376,25 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { }); io.info(`launched: ${mode}/${handle.id}`); + // Print the URL an external app POSTs to in order to wake this agent. + // + // Deploy is the moment someone is wiring the agent up, so it is the moment + // the URL is worth knowing. Without this, the only way to learn it was to be + // told the shape by hand — the endpoint is not otherwise advertised anywhere + // the deploying user looks. + // + // Uses the persona id (the deployed NAME), not `handle.id`: names are unique + // per workspace and survive a redeploy, while the uuid does not, so the name + // is what an app should hard-code. + if (mode === 'cloud' && cloudUrl && workspace) { + const triggerUrl = + `${cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(workspace)}` + + `/deployments/${encodeURIComponent(activePreflight.persona.id)}/trigger`; + io.info(`trigger from your app: POST ${triggerUrl}`); + io.info(' auth: Bearer (dashboard → Workspace → Deployment API tokens)'); + io.info(' body: any JSON object; it reaches the handler as the event payload'); + } + return { deploymentId: activePreflight.persona.id, mode, From 16307026aa5d8420ea53a1428bbf318e06a2ee86 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 14 Aug 2026 23:22:56 +0200 Subject: [PATCH 2/3] fix(deploy): print both trigger URL forms, and pin them with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings. **The name form is not universally valid.** Agent Workforce cloud resolves an agent by its deployed name, and that is the better thing for an app to hard-code — unique per workspace, and it survives a destroy/redeploy that changes the uuid. But `--cloud-url` may point at any compatible runtime, and a backend implementing only the original uuid-keyed route would 404 on the name form. Printing a single URL means printing one that is wrong for somebody, so both are printed: the name form leads, the id form is labelled portable. **The printed URL had no test.** The entire point is that a user hand-copies it, which makes the exact path load-bearing. A cloud-mode test now asserts both lines verbatim, so a future route change cannot silently mis-advertise them. It also covers the trailing-slash case, where a `cloudUrl` ending in `/` must not produce `test//api`. 251 tests pass. Co-Authored-By: Claude Opus 5 --- packages/deploy/src/deploy.test.ts | 61 ++++++++++++++++++++++++++++++ packages/deploy/src/deploy.ts | 21 ++++++---- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 2ab6d49b..f83d8571 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -2489,3 +2489,64 @@ test('deploy merges explicit --input with picker-collected values for the launch await cleanup(); } }); + +test('cloud deploy advertises the trigger URL in both addressing forms', async () => { + // The whole point of printing this is that a user hand-copies it into their + // app, so the exact path is load-bearing: a future change to the route shape + // must not silently mis-advertise it. + const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); + const io = createBufferedIO(); + try { + await deploy( + { + personaPath, + mode: 'cloud', + cloudUrl: 'https://cloud.example.test/', + io + }, + { + workspaceAuth: { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }, + providerConfigKeys: { async resolve() { return undefined; } }, + integrations: { + async isConnected() { return true; }, + async connect() { return { connectionId: 'conn-github' }; } + }, + bundle: successfulBundleStager(), + modes: { + cloud: { + async launch() { + return { + id: 'agent-uuid-1', + async stop() { /* no-op */ }, + done: Promise.resolve({ code: 0 }) + }; + } + } + } + } + ); + + const base = 'https://cloud.example.test/api/v1/workspaces/ws-test/deployments'; + const lines = io.messages.map((m) => m.message); + + // Name form leads: unique per workspace and stable across a redeploy. + assert.ok( + lines.includes(`trigger from your app: POST ${base}/demo/trigger`), + `missing name-form trigger URL in:\n${lines.join('\n')}` + ); + // Id form is the portable fallback for a compatible backend that only + // implements the original uuid-keyed route. + assert.ok( + lines.includes(` by id (portable): POST ${base}/agent-uuid-1/trigger`), + `missing id-form trigger URL in:\n${lines.join('\n')}` + ); + // The trailing slash on cloudUrl must not produce a double slash. + assert.ok(!lines.some((line) => line.includes('test//api'))); + } finally { + await cleanup(); + } +}); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 8843e125..518a64ac 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -383,14 +383,21 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { // told the shape by hand — the endpoint is not otherwise advertised anywhere // the deploying user looks. // - // Uses the persona id (the deployed NAME), not `handle.id`: names are unique - // per workspace and survive a redeploy, while the uuid does not, so the name - // is what an app should hard-code. + // Prints BOTH addressing forms, because which one works depends on the + // backend. Agent Workforce cloud resolves an agent by its deployed NAME, and + // that is the better thing for an app to hard-code: it is unique per + // workspace and survives a destroy/redeploy that changes the uuid. But + // `--cloud-url` may point at any compatible runtime (see modes/cloud), and a + // backend that only implements the original uuid-keyed route would 404 on the + // name form. Printing one URL means printing one that is wrong for somebody; + // the id line is the portable fallback. if (mode === 'cloud' && cloudUrl && workspace) { - const triggerUrl = - `${cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(workspace)}` + - `/deployments/${encodeURIComponent(activePreflight.persona.id)}/trigger`; - io.info(`trigger from your app: POST ${triggerUrl}`); + const base = + `${cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(workspace)}/deployments`; + io.info( + `trigger from your app: POST ${base}/${encodeURIComponent(activePreflight.persona.id)}/trigger`, + ); + io.info(` by id (portable): POST ${base}/${encodeURIComponent(handle.id)}/trigger`); io.info(' auth: Bearer (dashboard → Workspace → Deployment API tokens)'); io.info(' body: any JSON object; it reaches the handler as the event payload'); } From 8ba6b66d24032f4975f6aa85356e70a28c491b6a Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 14 Aug 2026 23:25:41 +0200 Subject: [PATCH 3/3] test(deploy): assert the auth and body guidance too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL alone is not usable instructions — a caller also needs the bearer-token line and the note that the body reaches the handler as the event payload. All three are copied together, so all three are now pinned. Co-Authored-By: Claude Opus 5 --- packages/deploy/src/deploy.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index f83d8571..d90399fc 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -2544,6 +2544,18 @@ test('cloud deploy advertises the trigger URL in both addressing forms', async ( lines.includes(` by id (portable): POST ${base}/agent-uuid-1/trigger`), `missing id-form trigger URL in:\n${lines.join('\n')}` ); + // The auth and body lines are part of the same copied instructions: a URL + // without them is not actually usable, so regressions there matter too. + assert.ok( + lines.includes( + ' auth: Bearer (dashboard \u2192 Workspace \u2192 Deployment API tokens)' + ), + `missing auth guidance in:\n${lines.join('\n')}` + ); + assert.ok( + lines.includes(' body: any JSON object; it reaches the handler as the event payload'), + `missing body guidance in:\n${lines.join('\n')}` + ); // The trailing slash on cloudUrl must not produce a double slash. assert.ok(!lines.some((line) => line.includes('test//api'))); } finally {