From 5cb21953e924dcc486571905dd56ec895b6724ef Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 15 Aug 2026 23:31:23 +0200 Subject: [PATCH 1/2] feat(cli): let `trigger` carry a payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentworkforce trigger` could only fire blanks. The endpoint behind it accepts any JSON object and hands it to the handler as the event payload — that is the whole point of an app-triggered agent — but the CLI sent no body, so every manual run looked exactly like a schedule firing. That is not a cosmetic gap. Verifying app-signal's Slack write after narrowing its mount was impossible from the CLI: the bare trigger took the agent's contentless path, which by design stays silent. Proving the payload path needed a hand-rolled curl with a separately minted token, and the repo already carries `scripts/trigger-agent.mjs` for exactly this reason. agentworkforce trigger app-signal '{"accountId":"acme","reason":"usage_spike"}' agentworkforce trigger app-signal --payload-file ./signal.json echo '{"accountId":"acme"}' | agentworkforce trigger app-signal --payload-file - Also adds `--idempotency-key`, which the endpoint already honours: retrying with the same key returns the original run instead of starting a second one. Details worth knowing: - A body-less POST and a `{}` body are NOT the same to cloud. The first is a contentless fire; the second is an app trigger carrying an empty object. Content-Type and body are attached only when a payload exists, never defaulted, and `buildTriggerRequest` is split out so a test pins that. - The payload must be a JSON object. Cloud wraps it as `{source:'app.trigger', payload:}` and handlers read named fields off it, so an array or scalar would earn a 202 and then a run that does nothing. Rejected before the request, with the reason. - Parsing stays synchronous and side-effect free; `--payload-file` reading is deferred to `resolvePayload`, so argument handling remains testable without a filesystem or stdin. - A trailing `{...}` positional is treated as the payload. Unambiguous: no agent id, deployed name, persona slug or persona id starts with a brace, and a non-JSON extra positional still errors as before. Co-Authored-By: Claude Opus 5 --- packages/cli/src/trigger-command.test.ts | 105 +++++++++++++ packages/cli/src/trigger-command.ts | 187 +++++++++++++++++++++-- 2 files changed, 281 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/trigger-command.test.ts b/packages/cli/src/trigger-command.test.ts index 4108e703..89e44a6b 100644 --- a/packages/cli/src/trigger-command.test.ts +++ b/packages/cli/src/trigger-command.test.ts @@ -1,10 +1,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { buildTriggerUrl, formatTriggerAuthHint, formatTriggerResult, parseTriggerArgs, + TRIGGER_USAGE, + buildTriggerRequest, + resolvePayload, parseTriggerResponse } from './trigger-command.js'; @@ -115,3 +121,102 @@ test('buildTriggerUrl preserves cloud base paths', () => { 'https://agentrelay.com/cloud/api/v1/workspaces/rw_123/deployments/agent-1/trigger' ); }); + +// ── payload ─────────────────────────────────────────────────────────────────── + +const quietIO = { stdout: () => {}, stderr: () => {} }; + +test('parseTriggerArgs takes a payload from a flag or a trailing JSON positional', () => { + const viaFlag = parseTriggerArgs(['app-signal', '--payload', '{"accountId":"acme"}']); + const viaPositional = parseTriggerArgs(['app-signal', '{"accountId":"acme"}']); + for (const parsed of [viaFlag, viaPositional]) { + assert.ok(!('help' in parsed)); + assert.equal(parsed.selector, 'app-signal'); + assert.deepEqual(parsed.payloadSource, { kind: 'inline', value: '{"accountId":"acme"}' }); + } +}); + +test('parseTriggerArgs still rejects a non-JSON extra positional', () => { + // The trailing-JSON form must not turn every stray argument into a payload — + // only something starting with a brace, which no selector can. + assert.throws(() => parseTriggerArgs(['app-signal', 'oops']), /unexpected positional argument "oops"/); +}); + +test('parseTriggerArgs accepts "-" as a payload file without reading it', () => { + // Parsing stays side-effect free: "-" means stdin, resolved later. The usual + // "flag expects a value" guard would otherwise reject the lone dash. + const parsed = parseTriggerArgs(['app-signal', '--payload-file', '-']); + assert.ok(!('help' in parsed)); + assert.deepEqual(parsed.payloadSource, { kind: 'file', value: '-' }); + assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file', '--json']), /--payload-file expects a value/); +}); + +test('parseTriggerArgs carries an idempotency key', () => { + const parsed = parseTriggerArgs(['app-signal', '--idempotency-key', 'evt-8412']); + assert.ok(!('help' in parsed)); + assert.equal(parsed.idempotencyKey, 'evt-8412'); +}); + +test('resolvePayload reads inline JSON and a file', async () => { + assert.deepEqual( + await resolvePayload({ kind: 'inline', value: '{"accountId":"acme"}' }, quietIO), + { accountId: 'acme' } + ); + const dir = await mkdtemp(join(tmpdir(), 'trigger-payload-')); + const file = join(dir, 'signal.json'); + await writeFile(file, '{"accountId":"acme","reason":"usage_spike"}', 'utf8'); + assert.deepEqual(await resolvePayload({ kind: 'file', value: file }, quietIO), { + accountId: 'acme', + reason: 'usage_spike' + }); +}); + +test('resolvePayload rejects anything a handler could not read', async () => { + // Cloud wraps the body as `{source:"app.trigger", payload:}` and + // handlers read named fields off it, so an array or scalar would be accepted + // with a 202 and then do nothing. Fail before the request instead. + await assert.rejects(() => resolvePayload({ kind: 'inline', value: '[1,2]' }, quietIO), /must be a JSON object/); + await assert.rejects(() => resolvePayload({ kind: 'inline', value: '"hi"' }, quietIO), /must be a JSON object/); + await assert.rejects(() => resolvePayload({ kind: 'inline', value: '{oops}' }, quietIO), /not valid JSON/); + await assert.rejects(() => resolvePayload({ kind: 'inline', value: ' ' }, quietIO), /omit it entirely/i); + await assert.rejects( + () => resolvePayload({ kind: 'file', value: '/nope/missing.json' }, quietIO), + /could not read payload file/ + ); +}); + +test('resolvePayload returns undefined for a contentless fire', async () => { + assert.equal(await resolvePayload(undefined, quietIO), undefined); +}); + +test('buildTriggerRequest sends a body only when there is a payload', () => { + // The distinction that matters: no body is a contentless fire, which is what + // a schedule looks like. `{}` is an app trigger carrying an empty object. + const bare = buildTriggerRequest({}, 'tok'); + assert.equal(bare.body, undefined); + assert.equal((bare.headers as Record)['content-type'], undefined); + assert.equal((bare.headers as Record)['idempotency-key'], undefined); + + const withPayload = buildTriggerRequest( + { payload: { accountId: 'acme' }, idempotencyKey: 'evt-8412' }, + 'tok' + ); + assert.equal(withPayload.body, '{"accountId":"acme"}'); + const headers = withPayload.headers as Record; + assert.equal(headers['content-type'], 'application/json'); + assert.equal(headers['idempotency-key'], 'evt-8412'); + assert.equal(headers.authorization, 'Bearer tok'); + + // An explicitly empty object is still a payload, not a contentless fire. + assert.equal(buildTriggerRequest({ payload: {} }, 'tok').body, '{}'); +}); + +test('usage documents the payload flags', () => { + // The gap this closes: a bare trigger is a contentless fire, so an agent + // whose real work is payload-driven could not be exercised from the CLI at + // all. Anyone reading --help should see that. + for (const flag of ['--payload ', '--payload-file ', '--idempotency-key ']) { + assert.ok(TRIGGER_USAGE.includes(flag), `usage should document ${flag}`); + } + assert.match(TRIGGER_USAGE, /contentless fire/); +}); diff --git a/packages/cli/src/trigger-command.ts b/packages/cli/src/trigger-command.ts index 4e192d59..2b7e51e3 100644 --- a/packages/cli/src/trigger-command.ts +++ b/packages/cli/src/trigger-command.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import { formatHttpErrorBody } from '@agentworkforce/deploy'; import { fetchDeployments, @@ -5,15 +6,30 @@ import { resolveDeploymentRequestContext } from './list-command.js'; -export const TRIGGER_USAGE = `usage: agentworkforce trigger [flags] - agentworkforce deployments trigger [flags] +export const TRIGGER_USAGE = `usage: agentworkforce trigger [payload-json] [flags] + agentworkforce deployments trigger [payload-json] [flags] Manually fire an active deployed persona through the cloud trigger endpoint. The selector may be an agent id, compact agent id, deployed name, persona slug, or persona id. Use this to force a fresh run for testing without waiting for the persona's normal schedule or integration event. +A payload is optional. With one, this is the same call a product makes to wake +an agent with data: the JSON object reaches the handler as the event payload. +Without one the agent gets a contentless fire, which is what a schedule looks +like — so an agent whose real work is payload-driven cannot be exercised by a +bare trigger. + +Examples: + agentworkforce trigger app-signal '{"accountId":"acme","reason":"usage_spike"}' + agentworkforce trigger app-signal --payload-file ./signal.json + echo '{"accountId":"acme"}' | agentworkforce trigger app-signal --payload-file - + Flags: + --payload JSON object to send as the event payload. + --payload-file Read the payload JSON from a file, or "-" for stdin. + --idempotency-key Retrying with the same key returns the original run + instead of starting a second one. --workspace Workforce workspace; defaults to the active one. --cloud-url Override the workforce cloud base URL. --json Emit the trigger response JSON. @@ -23,13 +39,36 @@ Flags: export interface TriggerOptions { selector: string; + /** + * Parsed JSON object sent as the request body. Absent means a contentless + * fire — the endpoint treats a body-less POST and a `{}` body differently, + * so this stays undefined rather than defaulting to an empty object. + */ + payload?: Record; + /** Sent as `Idempotency-Key`; a repeat returns the original run. */ + idempotencyKey?: string; workspace?: string; cloudUrl?: string; json?: boolean; noPrompt?: boolean; } -export type ParsedTriggerArgs = TriggerOptions | { help: true }; +/** Where the payload comes from, before any I/O has happened. */ +export interface PayloadSource { + kind: 'inline' | 'file'; + value: string; +} + +/** + * Argument parsing stays synchronous and side-effect free, so it can be tested + * without a filesystem or stdin. Reading `--payload-file` is deferred to + * {@link resolvePayload}, which `runTrigger` calls before dispatching. + */ +export interface TriggerArgs extends Omit { + payloadSource?: PayloadSource; +} + +export type ParsedTriggerArgs = TriggerArgs | { help: true }; export interface TriggerResponse { agentId: string; @@ -54,11 +93,34 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { let cloudUrl: string | undefined; let json = false; let noPrompt = false; + let payloadSource: { kind: 'inline' | 'file'; value: string } | undefined; + let idempotencyKey: string | undefined; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === '-h' || arg === '--help') { return { help: true }; + } else if (arg === '--payload') { + payloadSource = { kind: 'inline', value: expectValue('--payload', args[++i]) }; + } else if (arg.startsWith('--payload=')) { + payloadSource = { + kind: 'inline', + value: expectInlineValue('--payload', arg.slice('--payload='.length)) + }; + } else if (arg === '--payload-file') { + payloadSource = { kind: 'file', value: expectPathValue('--payload-file', args[++i]) }; + } else if (arg.startsWith('--payload-file=')) { + payloadSource = { + kind: 'file', + value: expectInlineValue('--payload-file', arg.slice('--payload-file='.length)) + }; + } else if (arg === '--idempotency-key') { + idempotencyKey = expectValue('--idempotency-key', args[++i]); + } else if (arg.startsWith('--idempotency-key=')) { + idempotencyKey = expectInlineValue( + '--idempotency-key', + arg.slice('--idempotency-key='.length) + ); } else if (arg === '--workspace') { workspace = expectValue('--workspace', args[++i]); } else if (arg.startsWith('--workspace=')) { @@ -77,6 +139,11 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { selector = arg; } else if (arg.startsWith('-')) { throw new Error(`trigger: unknown flag "${arg}"`); + } else if (arg.trimStart().startsWith('{') && !payloadSource) { + // A trailing `{...}` is the payload. Unambiguous against a selector: an + // agent id, deployed name, persona slug and persona id can none of them + // start with a brace. + payloadSource = { kind: 'inline', value: arg }; } else { throw new Error(`trigger: unexpected positional argument "${arg}"`); } @@ -88,6 +155,8 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { return { selector, + ...(payloadSource ? { payloadSource } : {}), + ...(idempotencyKey ? { idempotencyKey } : {}), ...(workspace ? { workspace } : {}), ...(cloudUrl ? { cloudUrl } : {}), ...(json ? { json: true } : {}), @@ -113,7 +182,12 @@ export async function runTrigger( } try { - const result = await triggerDeployment(opts); + const payload = await resolvePayload(opts.payloadSource, io); + const { payloadSource: _ignored, ...rest } = opts; + const result = await triggerDeployment({ + ...rest, + ...(payload ? { payload } : {}) + }); if (opts.json) { io.stdout(`${JSON.stringify(result, null, 2)}\n`); } else { @@ -145,13 +219,7 @@ export async function triggerDeployment(opts: TriggerOptions): Promise }` and handlers read named fields + * off it, so an array or a bare scalar would arrive as something no handler can + * use. Rejecting it here beats a 202 followed by a run that silently does + * nothing. + */ +export async function resolvePayload( + source: PayloadSource | undefined, + io: TriggerIO +): Promise | undefined> { + if (!source) return undefined; + + const raw = + source.kind === 'inline' + ? source.value + : source.value === '-' + ? await readStdin(io) + : await readFile(source.value, 'utf8').catch((err: unknown) => { + throw new Error( + `could not read payload file "${source.value}": ${err instanceof Error ? err.message : String(err)}` + ); + }); + + const label = source.kind === 'file' ? `payload file "${source.value}"` : 'payload'; + if (!raw.trim()) { + throw new Error(`${label} is empty. Omit it entirely to send a contentless fire.`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error( + `${label} must be a JSON object (got ${Array.isArray(parsed) ? 'an array' : typeof parsed}). ` + + 'The handler receives it as the event payload and reads named fields off it.' + ); + } + return parsed as Record; +} + +async function readStdin(io: TriggerIO): Promise { + if (process.stdin.isTTY) { + io.stderr('trigger: reading payload from stdin; end with Ctrl-D\n'); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +/** + * Build the POST for the trigger endpoint. + * + * A body-less POST and a `{}` body are NOT the same thing to cloud: the first + * is a contentless fire (indistinguishable from a schedule firing), the second + * is an app trigger carrying an empty object. So Content-Type and body are + * attached only when there is a payload, never defaulted. + */ +export function buildTriggerRequest( + opts: Pick, + token: string +): RequestInit { + return { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'user-agent': 'agentworkforce-cli/trigger', + ...(opts.payload ? { 'content-type': 'application/json' } : {}), + ...(opts.idempotencyKey ? { 'idempotency-key': opts.idempotencyKey } : {}) + }, + ...(opts.payload ? { body: JSON.stringify(opts.payload) } : {}) + }; +} + export function formatTriggerAuthHint(ctx: { authSource?: 'env' | 'cloud-session'; workspace: string; @@ -227,6 +378,20 @@ function readString(record: Record, key: string): string | unde return typeof value === 'string' && value.trim() ? value.trim() : undefined; } +/** + * Like {@link expectValue} but allows a leading "-", so `--payload-file -` + * (stdin) is not mistaken for a missing value. + */ +function expectPathValue(flag: string, value: string | undefined): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`trigger: ${flag} expects a value`); + } + if (value !== '-' && value.startsWith('-')) { + throw new Error(`trigger: ${flag} expects a value`); + } + return value; +} + function expectValue(flag: string, value: string | undefined): string { if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) { throw new Error(`trigger: ${flag} expects a value`); From f5e46fd69423651ad88d8b7ef2ec3c5f1c98508c Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 15 Aug 2026 23:38:16 +0200 Subject: [PATCH 2/2] fix(cli): guard both --payload-file spellings, and surface payload in top help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught both. `--payload-file=--json` skipped the path check the space-separated form applies, so it would have gone looking for a file literally named `--json`. The equals branch now uses the same `expectPathValue`, and a test pins both spellings — including that `--payload-file=-` still means stdin, which is the reason that guard cannot simply be "reject anything starting with a dash". The payload syntax was documented only under `trigger --help`, while the top-level `agentworkforce --help` still listed the old flag set. Someone reading the main help would conclude the CLI cannot send a payload, which is exactly the belief this change set exists to correct. Co-Authored-By: Claude Opus 5 --- packages/cli/src/cli-impl.ts | 13 +++++++++++-- packages/cli/src/trigger-command.test.ts | 7 +++++++ packages/cli/src/trigger-command.ts | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 3a7570d8..8cf194ed 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -271,10 +271,19 @@ Commands: Discover workspace integrations, connection status, and known trigger events. JSON output includes registration health when the cloud status API provides it. - trigger [flags] + trigger [payload-json] [flags] Manually fire an active deployed persona for testing. The selector accepts agent id, compact agent id, - deployed name, persona slug, or persona id. Flags: + deployed name, persona slug, or persona id. A payload is + optional; without one the agent gets a contentless fire, + which is what a schedule looks like. Flags: + --payload JSON object sent as the event + payload + --payload-file

Read the payload from a file, or + "-" for stdin + --idempotency-key + Retrying with the same key returns + the original run --workspace Workforce workspace; defaults to the active workspace --cloud-url Override the cloud base URL diff --git a/packages/cli/src/trigger-command.test.ts b/packages/cli/src/trigger-command.test.ts index 89e44a6b..7eb1ef74 100644 --- a/packages/cli/src/trigger-command.test.ts +++ b/packages/cli/src/trigger-command.test.ts @@ -148,7 +148,14 @@ test('parseTriggerArgs accepts "-" as a payload file without reading it', () => const parsed = parseTriggerArgs(['app-signal', '--payload-file', '-']); assert.ok(!('help' in parsed)); assert.deepEqual(parsed.payloadSource, { kind: 'file', value: '-' }); + assert.deepEqual( + (parseTriggerArgs(['app-signal', '--payload-file=-']) as { payloadSource?: unknown }).payloadSource, + { kind: 'file', value: '-' } + ); + // Both spellings guard alike — the equals form previously skipped the path + // check and would have gone looking for a file called "--json". assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file', '--json']), /--payload-file expects a value/); + assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file=--json']), /--payload-file expects a value/); }); test('parseTriggerArgs carries an idempotency key', () => { diff --git a/packages/cli/src/trigger-command.ts b/packages/cli/src/trigger-command.ts index 2b7e51e3..679da51c 100644 --- a/packages/cli/src/trigger-command.ts +++ b/packages/cli/src/trigger-command.ts @@ -112,7 +112,7 @@ export function parseTriggerArgs(args: readonly string[]): ParsedTriggerArgs { } else if (arg.startsWith('--payload-file=')) { payloadSource = { kind: 'file', - value: expectInlineValue('--payload-file', arg.slice('--payload-file='.length)) + value: expectPathValue('--payload-file', arg.slice('--payload-file='.length)) }; } else if (arg === '--idempotency-key') { idempotencyKey = expectValue('--idempotency-key', args[++i]);