From 5f9345b31f35722ffedcb65aca5246f1dc07c80c Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Fri, 13 Mar 2026 09:06:48 +0100 Subject: [PATCH 1/3] feat(cli): add audience-aware owner guidance --- cli/skills/spz/SKILL.md | 36 ++++ cli/src/index.ts | 125 ++++++++++++- cli/test/help.test.ts | 46 +++++ cli/test/provisioner-create.test.ts | 92 ++++++++++ cli/test/skillflag.test.ts | 3 + ...pz-audience-and-external-owner-guidance.md | 168 ++++++++++++++++++ 6 files changed, 462 insertions(+), 8 deletions(-) create mode 100644 cli/test/help.test.ts create mode 100644 docs/2026-03-13-spz-audience-and-external-owner-guidance.md diff --git a/cli/skills/spz/SKILL.md b/cli/skills/spz/SKILL.md index 4141b72..982c998 100644 --- a/cli/skills/spz/SKILL.md +++ b/cli/skills/spz/SKILL.md @@ -65,6 +65,29 @@ This mode is not the right fit for external automation. - `SPRITZ_BEARER_TOKEN`: service-principal bearer token - `SPRITZ_CONFIG_DIR`: config directory for profiles - `SPRITZ_PROFILE`: active profile name +- `AUDIENCE`: guidance audience for help and examples: `human` or `agent` + (default `human`) + +## Audience guidance + +`AUDIENCE` changes the human-readable guidance, not the API contract. + +- `AUDIENCE=human`: balanced help for direct-owner and external-owner use +- `AUDIENCE=agent`: prefer external-owner guidance, especially for messaging + platform integrations + +For `AUDIENCE=agent`: + +- do not ask the end user for an internal owner ID by default +- if the request comes from Discord, Slack, Teams, or a similar messaging + platform, use the platform-native user ID from that platform as the external + subject +- for those flows, use `--owner-provider` and `--owner-subject` +- never pass a messaging-platform user ID through `--owner-id` +- if provider, subject, preset, or tenant context is unclear, ask for + clarification instead of guessing +- if external owner resolution fails, tell the caller the user needs to connect + their account in the product or integration that owns the mapping ## Zenobot and other preconfigured bot images @@ -110,6 +133,8 @@ Rules: - for Discord, Slack, Teams, and similar platform-triggered creates, pass the external platform user through `--owner-provider` and `--owner-subject` - never pass a Discord, Slack, or Teams user ID through `--owner-id` +- do not ask for or depend on an internal owner ID unless it is already known + from a trusted internal context - use `--owner-id` only when you already have the canonical internal Spritz owner ID and intend a direct internal/admin create - the service principal is only the actor @@ -131,6 +156,14 @@ Create from a preset for a known internal owner: spz create --preset openclaw --owner-id user-123 --idle-ttl 24h --ttl 168h --idempotency-key req-123 --json ``` +If external owner resolution fails, explain it like this: + +```text +The external account could not be resolved to a Spritz owner. +Ask the user to connect their account in the product or integration that owns +this identity mapping, then retry the create request. +``` + Create from an explicit image: ```bash @@ -174,6 +207,9 @@ spz profile use staging - prefer bearer-token auth for bots - for chat-platform-triggered creates, prefer external owner flags over direct `--owner-id` +- do not assume the caller already knows an internal owner ID +- if the required provider, subject, tenant, or preset is unclear, ask for the + missing detail instead of guessing - treat the create response as the source of truth for the access URL - do not construct workspace URLs yourself - use idempotency keys for any retried or externally triggered create operation diff --git a/cli/src/index.ts b/cli/src/index.ts index da59bb5..80046bb 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -31,6 +31,7 @@ type TerminalSessionInfo = { }; type SkillflagModule = typeof import('skillflag'); +type Audience = 'human' | 'agent'; type TtyContext = { ttyPath: string | null; @@ -59,6 +60,20 @@ const sttyBinary = process.env.SPRITZ_STTY_BINARY || 'stty'; const resetBinary = process.env.SPRITZ_RESET_BINARY || 'reset'; let skillflagModulePromise: Promise | undefined; +class SpritzRequestError extends Error { + statusCode: number; + code?: string; + data?: any; + + constructor(message: string, options: { statusCode: number; code?: string; data?: any }) { + super(message); + this.name = 'SpritzRequestError'; + this.statusCode = options.statusCode; + this.code = options.code; + this.data = options.data; + } +} + function loadSkillflagModule(): Promise { skillflagModulePromise ??= import('skillflag'); return skillflagModulePromise; @@ -370,12 +385,50 @@ function startTtyWatchdog(context: TtyContext): (() => void) | null { }; } -function usage() { +function resolveAudience(value = process.env.AUDIENCE): Audience { + return value?.trim().toLowerCase() === 'agent' ? 'agent' : 'human'; +} + +function createUsage(audience = resolveAudience()) { + const ownerNotes = audience === 'agent' + ? `Ownership guidance: + - If the request came from Discord, Slack, Teams, or another messaging platform, + use the platform-native user ID with --owner-provider and --owner-subject. + - Never pass a messaging-platform user ID through --owner-id. + - If provider, subject, preset, or tenant context is unclear, ask for + clarification instead of guessing. + - If external owner resolution fails, tell the caller the user needs to + connect their account, then retry with the same platform user ID. +` + : `Ownership guidance: + - Use --owner-provider and --owner-subject when you only know a platform-native + user ID such as a Discord, Slack, or Teams user. + - Use --owner-id only when you already know the canonical internal Spritz owner ID. + - If provider, subject, preset, or tenant context is unclear, clarify it before + running the create command. +`; + + console.log(`Spritz create + +Usage: + spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-provider --owner-subject [--owner-tenant ] | --owner-id ] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] + +Environment: + AUDIENCE (current: ${audience}) + +Examples: + spritz create --preset claude-code --owner-provider discord --owner-subject 123456789012345678 --source discord --request-id discord-123 --idempotency-key discord-123 --json + spritz create --preset openclaw --owner-id user-123 --idempotency-key req-123 --json + +${ownerNotes}`); +} + +function usage(audience = resolveAudience()) { console.log(`Spritz CLI Usage: spritz list [--namespace ] - spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-id | --owner-provider --owner-subject [--owner-tenant ]] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] + spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-provider --owner-subject [--owner-tenant ] | --owner-id ] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] spritz suggest-name [--preset ] [--image ] [--name-prefix ] [--namespace ] spritz delete [--namespace ] spritz open [--namespace ] @@ -399,12 +452,44 @@ Environment: SPRITZ_API_HEADER_ID, SPRITZ_API_HEADER_EMAIL, SPRITZ_API_HEADER_TEAMS SPRITZ_TERMINAL_TRANSPORT (default: ${terminalTransportDefault}) SPRITZ_PROFILE, SPRITZ_CONFIG_DIR + AUDIENCE (default: human, current: ${audience}) Notes: + ${audience === 'agent' + ? 'If a request originated from a messaging app, prefer --owner-provider and --owner-subject with the platform-native user ID.' + : 'Use `spritz create --help` for detailed owner guidance and examples.'} When ZMX sessions are enabled, detach with Ctrl+\\ and reconnect later. `); } +function missingOwnerInputMessage(audience = resolveAudience()): string { + if (audience === 'agent') { + return [ + 'owner input is required.', + 'If this request came from a messaging app, use the platform-native user ID with --owner-provider and --owner-subject.', + 'Do not ask for or pass a messaging-platform user ID as --owner-id.', + 'If the provider, subject, preset, or tenant is unclear, ask for clarification before retrying.', + ].join(' '); + } + return [ + 'owner input is required.', + 'Use --owner-provider and --owner-subject when you only know a messaging-platform user ID.', + 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', + ].join(' '); +} + +function unresolvedExternalOwnerMessage(error: SpritzRequestError, audience = resolveAudience()): string { + const provider = typeof error.data?.identity?.provider === 'string' ? error.data.identity.provider : 'external'; + const lines = [ + `The ${provider} account could not be resolved to a Spritz owner.`, + 'Ask the user to connect their account in the product or integration that owns this identity mapping, then retry the create request.', + ]; + if (audience === 'agent') { + lines.push('Keep using the platform-native user ID with --owner-provider and --owner-subject.'); + } + return lines.join('\n'); +} + function argValue(flag: string): string | undefined { const idx = rest.indexOf(flag); if (idx === -1) return undefined; @@ -617,12 +702,16 @@ async function request(path: string, init?: RequestInit) { } const jsend = isJSend(data) ? data : null; if (!res.ok || (res.ok && jsend && jsend.status !== 'success')) { + const errorCode = + (jsend && typeof jsend.data?.error === 'string' ? jsend.data.error : undefined) || + undefined; + const errorData = jsend?.data; const message = (jsend && (jsend.message || jsend.data?.message || jsend.data?.error)) || text || res.statusText || 'Request failed'; - throw new Error(message); + throw new SpritzRequestError(message, { statusCode: res.status, code: errorCode, data: errorData }); } if (res.status === 204) return null; if (jsend) return jsend.data ?? null; @@ -948,10 +1037,19 @@ async function main() { } if (!command || command === 'help' || command === '--help') { + if (command === 'help' && rest[0] === 'create') { + createUsage(); + return; + } usage(); return; } + if (command === 'create' && hasFlag('--help')) { + createUsage(); + return; + } + if (command === 'profile') { const action = rest[0]; const config = await loadConfig(); @@ -1132,6 +1230,9 @@ async function main() { const ownerId = usingExternalOwner ? undefined : explicitOwnerId || (token?.trim() ? process.env.SPRITZ_OWNER_ID : await resolveDefaultOwnerId()); + if (!usingExternalOwner && !ownerId) { + throw new Error(missingOwnerInputMessage()); + } const idleTtl = argValue('--idle-ttl'); const ttl = argValue('--ttl'); const idempotencyKey = argValue('--idempotency-key'); @@ -1167,11 +1268,19 @@ async function main() { if (branch) body.spec.repo.branch = branch; } - const data = await request('/spritzes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + let data: any; + try { + data = await request('/spritzes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (error) { + if (error instanceof SpritzRequestError && error.code === 'external_identity_unresolved') { + throw new Error(unresolvedExternalOwnerMessage(error)); + } + throw error; + } console.log(JSON.stringify(data, null, 2)); return; diff --git a/cli/test/help.test.ts b/cli/test/help.test.ts new file mode 100644 index 0000000..fe5f752 --- /dev/null +++ b/cli/test/help.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliPath = path.join(__dirname, '..', 'src', 'index.ts'); + +async function runCli(args: string[], env: NodeJS.ProcessEnv = process.env) { + const child = spawn(process.execPath, ['--import', 'tsx', cliPath, ...args], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const code = await new Promise((resolve) => child.on('exit', resolve)); + return { code, stdout, stderr }; +} + +test('create help defaults to human audience', async () => { + const result = await runCli(['create', '--help'], { ...process.env, AUDIENCE: '' }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /AUDIENCE \(current: human\)/); + assert.match(result.stdout, /Use --owner-provider and --owner-subject when you only know a platform-native\s+user ID/i); +}); + +test('create help for agent audience prefers external owner guidance', async () => { + const result = await runCli(['create', '--help'], { ...process.env, AUDIENCE: 'agent' }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /AUDIENCE \(current: agent\)/); + assert.match(result.stdout, /use the platform-native user ID with --owner-provider and --owner-subject/i); + assert.match(result.stdout, /Never pass a messaging-platform user ID through --owner-id/i); + assert.match(result.stdout, /connect their account/i); + assert.match(result.stdout, /ask for\s+clarification instead of guessing/i); +}); diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts index 695c7f0..3afdb93 100644 --- a/cli/test/provisioner-create.test.ts +++ b/cli/test/provisioner-create.test.ts @@ -214,6 +214,98 @@ test('create rejects mixed owner-id and external owner flags', async () => { assert.match(stderr, /mutually exclusive/); }); +test('create explains unresolved external owners with connect-account guidance', async (t) => { + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + req.on('end', () => { + res.writeHead(409, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'fail', + data: { + message: 'external identity is unresolved', + error: 'external_identity_unresolved', + identity: { + provider: 'discord', + subject: '123456789012345678', + }, + }, + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + cliPath, + 'create', + '--preset', + 'openclaw', + '--owner-provider', + 'discord', + '--owner-subject', + '123456789012345678', + ], + { + env: { + ...process.env, + SPRITZ_API_URL: `http://127.0.0.1:${address.port}/api`, + SPRITZ_BEARER_TOKEN: 'service-token', + SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')), + AUDIENCE: 'agent', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.notEqual(exitCode, 0, 'spz create should fail for unresolved external owner'); + assert.match(stderr, /could not be resolved to a Spritz owner/i); + assert.match(stderr, /connect their account/i); + assert.match(stderr, /--owner-provider and --owner-subject/i); +}); + +test('create without owner input guides agent callers toward external owner flags', async () => { + const child = spawn( + process.execPath, + ['--import', 'tsx', cliPath, 'create', '--preset', 'openclaw'], + { + env: { + ...process.env, + SPRITZ_API_URL: 'http://127.0.0.1:9/api', + SPRITZ_BEARER_TOKEN: 'service-token', + SPRITZ_CONFIG_DIR: mkdtempSync(path.join(os.tmpdir(), 'spz-config-')), + AUDIENCE: 'agent', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const exitCode = await new Promise((resolve) => child.on('exit', resolve)); + assert.notEqual(exitCode, 0, 'spz create should fail when no owner input is provided'); + assert.match(stderr, /owner input is required/i); + assert.match(stderr, /platform-native user ID with --owner-provider and --owner-subject/i); + assert.match(stderr, /ask for clarification/i); +}); + test('create falls back to local owner identity without bearer auth', async (t) => { let requestBody: any = null; let requestHeaders: http.IncomingHttpHeaders | null = null; diff --git a/cli/test/skillflag.test.ts b/cli/test/skillflag.test.ts index 4d16741..1ff4efc 100644 --- a/cli/test/skillflag.test.ts +++ b/cli/test/skillflag.test.ts @@ -39,6 +39,9 @@ test('skillflag show returns the bundled spz skill body', async () => { assert.equal(result.code, 0, result.stderr); assert.match(result.stdout, /# spz/); assert.match(result.stdout, /service-principal create flow/i); + assert.match(result.stdout, /AUDIENCE.*human.*agent/i); assert.match(result.stdout, /--owner-provider discord/i); assert.match(result.stdout, /never pass a Discord, Slack, or Teams user ID through `--owner-id`/i); + assert.match(result.stdout, /connect their account/i); + assert.match(result.stdout, /ask for\s+clarification instead of guessing/i); }); diff --git a/docs/2026-03-13-spz-audience-and-external-owner-guidance.md b/docs/2026-03-13-spz-audience-and-external-owner-guidance.md new file mode 100644 index 0000000..55b87bb --- /dev/null +++ b/docs/2026-03-13-spz-audience-and-external-owner-guidance.md @@ -0,0 +1,168 @@ +--- +date: 2026-03-13 +author: Onur Solmaz +title: spz Audience and External Owner Guidance +tags: [spritz, spz, cli, audience, external-identity] +--- + +## Overview + +This document defines the `AUDIENCE` environment variable contract for `spz` +and the default guidance for direct-owner versus external-owner create flows. + +The goal is to keep the CLI behavior stable while making the human-readable +guidance safer for bots, messaging agents, and service-principal automation. + +## `AUDIENCE` Contract + +- `AUDIENCE` is a human-readable guidance switch for `spz`. +- Allowed values: + - `human` + - `agent` +- Default: `human` + +`AUDIENCE` affects: + +- `spz --help` +- subcommand help such as `spz create --help` +- bundled skill text shipped with the CLI +- human-readable examples and remediation hints + +`AUDIENCE` must not change: + +- API requests +- flag semantics +- exit codes +- JSON response shape + +## Ownership Guidance Defaults + +The default guidance for `spz create` must not assume the caller already knows +an internal owner ID. + +Rules: + +- direct `--owner-id` is a direct-owner path for internal, admin, or manual + callers that already know the canonical internal owner ID +- external-owner flags are the default guidance whenever the caller only knows + a platform-native user identity +- documentation and skill examples must not present `--owner-id` as the normal + baseline for messaging-platform-driven creates + +## Agent Audience Rules + +When `AUDIENCE=agent`, the CLI help and bundled skill must guide the caller to +use external-owner resolution by default. + +Agent guidance must state: + +- if the request comes from a messaging platform such as Discord, Slack, + Microsoft Teams, Mattermost, Google Chat, or similar, always use the stable + user ID from that platform as the external subject +- for those flows, use: + - `--owner-provider ` + - `--owner-subject ` +- never pass a messaging-platform user ID through `--owner-id` +- do not ask the end user for an internal owner ID by default + +Examples: + +```bash +spz create \ + --preset claude-code \ + --owner-provider discord \ + --owner-subject 123456789012345678 \ + --source discord \ + --request-id discord-123 \ + --idempotency-key discord-123 \ + --json +``` + +```bash +spz create \ + --preset openclaw \ + --owner-provider msteams \ + --owner-subject 6f0f9d4f-9b0e-4d52-8c3a-ef0fd64b9b9f \ + --owner-tenant 11111111-2222-3333-4444-555555555555 \ + --source msteams \ + --request-id teams-123 \ + --idempotency-key teams-123 \ + --json +``` + +## Human Audience Rules + +When `AUDIENCE=human`, the CLI may present both direct-owner and external-owner +paths, but it must still avoid implying that internal owner IDs are generally +required. + +Human guidance should: + +- show external-owner examples for messaging-platform integrations +- keep `--owner-id` documented as an explicit direct-owner mode +- explain the tradeoff in plain language when both paths are shown + +## Unresolved External Identity Behavior + +When the create flow is using an external owner and resolution fails because the +external identity is not linked to a Spritz owner, the human-readable error +guidance should tell the caller that the user needs to connect their account. + +This guidance should stay generic and deployment-agnostic. + +Preferred wording shape: + +```text +The external account could not be resolved to a Spritz owner. +Ask the user to connect their account in the product or integration that owns +this identity mapping, then retry the create request. +``` + +This must not tell the caller to ask the user for an internal owner ID as the +default fallback. + +## Clarification Behavior + +When the required create inputs are not clear, the bundled skill and +human-readable guidance should tell the caller to ask for clarification instead +of guessing. + +Examples of unclear input: + +- the requested preset is ambiguous +- the platform is unclear +- the external subject is unavailable from message metadata +- tenant-scoped providers are missing tenant context + +Preferred behavior: + +- ask for the missing detail explicitly +- do not guess an internal owner ID +- do not silently switch from external-owner mode to direct-owner mode + +## Skill and Help Requirements + +The bundled `spz` skill and agent-facing help must: + +- document `AUDIENCE=agent` +- present external-owner create flows first for platform integrations +- tell agents to use the platform-native user ID from the incoming platform +- explain unresolved-owner remediation as “connect the account” +- tell agents to ask for clarification when provider, subject, or preset is + unclear + +## Validation + +Validation for this contract should include: + +- a bundled-skill test that asserts `AUDIENCE=agent` guidance is present +- a bundled-skill test that asserts messaging-platform flows use + `--owner-provider` and `--owner-subject` +- a bundled-skill test that asserts unresolved external owners are explained as + “connect the account” + +## References + +- `docs/2026-03-11-external-provisioner-and-service-principal-architecture.md` +- `docs/2026-03-12-external-identity-resolution-api-architecture.md` +- `cli/skills/spz/SKILL.md` From cf0860a42f982138684cbff13bc0095810f97d1d Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Fri, 13 Mar 2026 09:11:42 +0100 Subject: [PATCH 2/3] refactor(cli): centralize audience guidance --- cli/skills/spz/SKILL.md | 25 +--- cli/src/index.ts | 125 ++++++++++-------- cli/test/skillflag.test.ts | 1 - ...pz-audience-and-external-owner-guidance.md | 24 ++-- 4 files changed, 88 insertions(+), 87 deletions(-) diff --git a/cli/skills/spz/SKILL.md b/cli/skills/spz/SKILL.md index 982c998..b2050d7 100644 --- a/cli/skills/spz/SKILL.md +++ b/cli/skills/spz/SKILL.md @@ -65,29 +65,6 @@ This mode is not the right fit for external automation. - `SPRITZ_BEARER_TOKEN`: service-principal bearer token - `SPRITZ_CONFIG_DIR`: config directory for profiles - `SPRITZ_PROFILE`: active profile name -- `AUDIENCE`: guidance audience for help and examples: `human` or `agent` - (default `human`) - -## Audience guidance - -`AUDIENCE` changes the human-readable guidance, not the API contract. - -- `AUDIENCE=human`: balanced help for direct-owner and external-owner use -- `AUDIENCE=agent`: prefer external-owner guidance, especially for messaging - platform integrations - -For `AUDIENCE=agent`: - -- do not ask the end user for an internal owner ID by default -- if the request comes from Discord, Slack, Teams, or a similar messaging - platform, use the platform-native user ID from that platform as the external - subject -- for those flows, use `--owner-provider` and `--owner-subject` -- never pass a messaging-platform user ID through `--owner-id` -- if provider, subject, preset, or tenant context is unclear, ask for - clarification instead of guessing -- if external owner resolution fails, tell the caller the user needs to connect - their account in the product or integration that owns the mapping ## Zenobot and other preconfigured bot images @@ -137,6 +114,8 @@ Rules: from a trusted internal context - use `--owner-id` only when you already have the canonical internal Spritz owner ID and intend a direct internal/admin create +- if provider, subject, preset, or tenant context is unclear, ask for + clarification instead of guessing - the service principal is only the actor - the same `idempotency-key` and same request should replay the same workspace - the same `idempotency-key` with a different request should fail with conflict diff --git a/cli/src/index.ts b/cli/src/index.ts index 80046bb..c6ba2f3 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -32,6 +32,13 @@ type TerminalSessionInfo = { type SkillflagModule = typeof import('skillflag'); type Audience = 'human' | 'agent'; +type AudienceGuidance = { + audience: Audience; + usageNote: string; + createOwnershipGuidance: string[]; + missingOwnerInputGuidance: string[]; + unresolvedExternalOwnerGuidance: (provider: string) => string[]; +}; type TtyContext = { ttyPath: string | null; @@ -389,24 +396,58 @@ function resolveAudience(value = process.env.AUDIENCE): Audience { return value?.trim().toLowerCase() === 'agent' ? 'agent' : 'human'; } -function createUsage(audience = resolveAudience()) { - const ownerNotes = audience === 'agent' - ? `Ownership guidance: - - If the request came from Discord, Slack, Teams, or another messaging platform, - use the platform-native user ID with --owner-provider and --owner-subject. - - Never pass a messaging-platform user ID through --owner-id. - - If provider, subject, preset, or tenant context is unclear, ask for - clarification instead of guessing. - - If external owner resolution fails, tell the caller the user needs to - connect their account, then retry with the same platform user ID. -` - : `Ownership guidance: - - Use --owner-provider and --owner-subject when you only know a platform-native - user ID such as a Discord, Slack, or Teams user. - - Use --owner-id only when you already know the canonical internal Spritz owner ID. - - If provider, subject, preset, or tenant context is unclear, clarify it before - running the create command. -`; +const audienceGuidanceByAudience: Record = { + human: { + audience: 'human', + usageNote: 'Use `spritz create --help` for detailed owner guidance and examples.', + createOwnershipGuidance: [ + 'Use --owner-provider and --owner-subject when you only know a platform-native user ID such as a Discord, Slack, or Teams user.', + 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', + 'If provider, subject, preset, or tenant context is unclear, clarify it before running the create command.', + ], + missingOwnerInputGuidance: [ + 'owner input is required.', + 'Use --owner-provider and --owner-subject when you only know a messaging-platform user ID.', + 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', + ], + unresolvedExternalOwnerGuidance: (provider: string) => [ + `The ${provider} account could not be resolved to a Spritz owner.`, + 'Ask the user to connect their account in the product or integration that owns this identity mapping, then retry the create request.', + ], + }, + agent: { + audience: 'agent', + usageNote: 'If a request originated from a messaging app, prefer --owner-provider and --owner-subject with the platform-native user ID.', + createOwnershipGuidance: [ + 'If the request came from Discord, Slack, Teams, or another messaging platform, use the platform-native user ID with --owner-provider and --owner-subject.', + 'Never pass a messaging-platform user ID through --owner-id.', + 'If provider, subject, preset, or tenant context is unclear, ask for clarification instead of guessing.', + 'If external owner resolution fails, tell the caller the user needs to connect their account, then retry with the same platform user ID.', + ], + missingOwnerInputGuidance: [ + 'owner input is required.', + 'If this request came from a messaging app, use the platform-native user ID with --owner-provider and --owner-subject.', + 'Do not ask for or pass a messaging-platform user ID as --owner-id.', + 'If the provider, subject, preset, or tenant is unclear, ask for clarification before retrying.', + ], + unresolvedExternalOwnerGuidance: (provider: string) => [ + `The ${provider} account could not be resolved to a Spritz owner.`, + 'Ask the user to connect their account in the product or integration that owns this identity mapping, then retry the create request.', + 'Keep using the platform-native user ID with --owner-provider and --owner-subject.', + ], + }, +}; + +function guidanceForAudience(value = process.env.AUDIENCE): AudienceGuidance { + return audienceGuidanceByAudience[resolveAudience(value)]; +} + +function renderBullets(lines: string[]): string { + return lines.map((line) => ` - ${line}`).join('\n'); +} + +function createUsage(guidance = guidanceForAudience()) { + const ownerNotes = `Ownership guidance:\n${renderBullets(guidance.createOwnershipGuidance)}\n`; console.log(`Spritz create @@ -414,7 +455,7 @@ Usage: spritz create [name] [--preset ] [--image ] [--repo ] [--branch ] [--owner-provider --owner-subject [--owner-tenant ] | --owner-id ] [--idle-ttl ] [--ttl ] [--idempotency-key ] [--source ] [--request-id ] [--name-prefix ] [--namespace ] Environment: - AUDIENCE (current: ${audience}) + AUDIENCE (current: ${guidance.audience}) Examples: spritz create --preset claude-code --owner-provider discord --owner-subject 123456789012345678 --source discord --request-id discord-123 --idempotency-key discord-123 --json @@ -423,7 +464,7 @@ Examples: ${ownerNotes}`); } -function usage(audience = resolveAudience()) { +function usage(guidance = guidanceForAudience()) { console.log(`Spritz CLI Usage: @@ -452,42 +493,21 @@ Environment: SPRITZ_API_HEADER_ID, SPRITZ_API_HEADER_EMAIL, SPRITZ_API_HEADER_TEAMS SPRITZ_TERMINAL_TRANSPORT (default: ${terminalTransportDefault}) SPRITZ_PROFILE, SPRITZ_CONFIG_DIR - AUDIENCE (default: human, current: ${audience}) + AUDIENCE (default: human, current: ${guidance.audience}) Notes: - ${audience === 'agent' - ? 'If a request originated from a messaging app, prefer --owner-provider and --owner-subject with the platform-native user ID.' - : 'Use `spritz create --help` for detailed owner guidance and examples.'} + ${guidance.usageNote} When ZMX sessions are enabled, detach with Ctrl+\\ and reconnect later. `); } -function missingOwnerInputMessage(audience = resolveAudience()): string { - if (audience === 'agent') { - return [ - 'owner input is required.', - 'If this request came from a messaging app, use the platform-native user ID with --owner-provider and --owner-subject.', - 'Do not ask for or pass a messaging-platform user ID as --owner-id.', - 'If the provider, subject, preset, or tenant is unclear, ask for clarification before retrying.', - ].join(' '); - } - return [ - 'owner input is required.', - 'Use --owner-provider and --owner-subject when you only know a messaging-platform user ID.', - 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', - ].join(' '); +function missingOwnerInputMessage(guidance = guidanceForAudience()): string { + return guidance.missingOwnerInputGuidance.join(' '); } -function unresolvedExternalOwnerMessage(error: SpritzRequestError, audience = resolveAudience()): string { +function unresolvedExternalOwnerMessage(error: SpritzRequestError, guidance = guidanceForAudience()): string { const provider = typeof error.data?.identity?.provider === 'string' ? error.data.identity.provider : 'external'; - const lines = [ - `The ${provider} account could not be resolved to a Spritz owner.`, - 'Ask the user to connect their account in the product or integration that owns this identity mapping, then retry the create request.', - ]; - if (audience === 'agent') { - lines.push('Keep using the platform-native user ID with --owner-provider and --owner-subject.'); - } - return lines.join('\n'); + return guidance.unresolvedExternalOwnerGuidance(provider).join('\n'); } function argValue(flag: string): string | undefined { @@ -1027,6 +1047,7 @@ async function resolveNamespace(): Promise { } async function main() { + const guidance = guidanceForAudience(); if (shouldMaybeHandleSkillflag(process.argv)) { const { findSkillsRoot, maybeHandleSkillflag } = await loadSkillflagModule(); await maybeHandleSkillflag(process.argv, { @@ -1038,15 +1059,15 @@ async function main() { if (!command || command === 'help' || command === '--help') { if (command === 'help' && rest[0] === 'create') { - createUsage(); + createUsage(guidance); return; } - usage(); + usage(guidance); return; } if (command === 'create' && hasFlag('--help')) { - createUsage(); + createUsage(guidance); return; } @@ -1231,7 +1252,7 @@ async function main() { ? undefined : explicitOwnerId || (token?.trim() ? process.env.SPRITZ_OWNER_ID : await resolveDefaultOwnerId()); if (!usingExternalOwner && !ownerId) { - throw new Error(missingOwnerInputMessage()); + throw new Error(missingOwnerInputMessage(guidance)); } const idleTtl = argValue('--idle-ttl'); const ttl = argValue('--ttl'); @@ -1277,7 +1298,7 @@ async function main() { }); } catch (error) { if (error instanceof SpritzRequestError && error.code === 'external_identity_unresolved') { - throw new Error(unresolvedExternalOwnerMessage(error)); + throw new Error(unresolvedExternalOwnerMessage(error, guidance)); } throw error; } diff --git a/cli/test/skillflag.test.ts b/cli/test/skillflag.test.ts index 1ff4efc..f4752d7 100644 --- a/cli/test/skillflag.test.ts +++ b/cli/test/skillflag.test.ts @@ -39,7 +39,6 @@ test('skillflag show returns the bundled spz skill body', async () => { assert.equal(result.code, 0, result.stderr); assert.match(result.stdout, /# spz/); assert.match(result.stdout, /service-principal create flow/i); - assert.match(result.stdout, /AUDIENCE.*human.*agent/i); assert.match(result.stdout, /--owner-provider discord/i); assert.match(result.stdout, /never pass a Discord, Slack, or Teams user ID through `--owner-id`/i); assert.match(result.stdout, /connect their account/i); diff --git a/docs/2026-03-13-spz-audience-and-external-owner-guidance.md b/docs/2026-03-13-spz-audience-and-external-owner-guidance.md index 55b87bb..3fd04ad 100644 --- a/docs/2026-03-13-spz-audience-and-external-owner-guidance.md +++ b/docs/2026-03-13-spz-audience-and-external-owner-guidance.md @@ -25,7 +25,6 @@ guidance safer for bots, messaging agents, and service-principal automation. - `spz --help` - subcommand help such as `spz create --help` -- bundled skill text shipped with the CLI - human-readable examples and remediation hints `AUDIENCE` must not change: @@ -51,8 +50,8 @@ Rules: ## Agent Audience Rules -When `AUDIENCE=agent`, the CLI help and bundled skill must guide the caller to -use external-owner resolution by default. +When `AUDIENCE=agent`, the CLI help and printed remediation must guide the +caller to use external-owner resolution by default. Agent guidance must state: @@ -123,9 +122,8 @@ default fallback. ## Clarification Behavior -When the required create inputs are not clear, the bundled skill and -human-readable guidance should tell the caller to ask for clarification instead -of guessing. +When the required create inputs are not clear, the CLI guidance should tell the +caller to ask for clarification instead of guessing. Examples of unclear input: @@ -142,24 +140,28 @@ Preferred behavior: ## Skill and Help Requirements -The bundled `spz` skill and agent-facing help must: +The bundled `spz` skill must stay generic and correct: -- document `AUDIENCE=agent` - present external-owner create flows first for platform integrations -- tell agents to use the platform-native user ID from the incoming platform - explain unresolved-owner remediation as “connect the account” -- tell agents to ask for clarification when provider, subject, or preset is +- tell callers to ask for clarification when provider, subject, or preset is unclear +The CLI help and printed remediation must be audience-aware: + +- `AUDIENCE=human` prints balanced direct-owner and external-owner guidance +- `AUDIENCE=agent` prints messaging-platform-first ownership guidance +- the modality switch must live in code, not in duplicated static skill text + ## Validation Validation for this contract should include: -- a bundled-skill test that asserts `AUDIENCE=agent` guidance is present - a bundled-skill test that asserts messaging-platform flows use `--owner-provider` and `--owner-subject` - a bundled-skill test that asserts unresolved external owners are explained as “connect the account” +- CLI help tests for `AUDIENCE=human` and `AUDIENCE=agent` ## References From b4b68c1ad2e27001f3c6a358f6d2e57c97b17f1f Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Fri, 13 Mar 2026 09:15:40 +0100 Subject: [PATCH 3/3] feat(cli): add agent reporting guidance --- cli/skills/spz/SKILL.md | 3 +++ cli/src/index.ts | 13 ++++++++++++- cli/test/help.test.ts | 2 ++ cli/test/skillflag.test.ts | 2 ++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cli/skills/spz/SKILL.md b/cli/skills/spz/SKILL.md index b2050d7..593696b 100644 --- a/cli/skills/spz/SKILL.md +++ b/cli/skills/spz/SKILL.md @@ -189,6 +189,9 @@ spz profile use staging - do not assume the caller already knows an internal owner ID - if the required provider, subject, tenant, or preset is unclear, ask for the missing detail instead of guessing +- when reporting a successful create back in a messaging app, tag the person + who requested it and include what was created plus the returned URLs for + opening it - treat the create response as the source of truth for the access URL - do not construct workspace URLs yourself - use idempotency keys for any retried or externally triggered create operation diff --git a/cli/src/index.ts b/cli/src/index.ts index c6ba2f3..d9503c5 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -36,6 +36,7 @@ type AudienceGuidance = { audience: Audience; usageNote: string; createOwnershipGuidance: string[]; + reportingGuidance: string[]; missingOwnerInputGuidance: string[]; unresolvedExternalOwnerGuidance: (provider: string) => string[]; }; @@ -405,6 +406,9 @@ const audienceGuidanceByAudience: Record = { 'Use --owner-id only when you already know the canonical internal Spritz owner ID.', 'If provider, subject, preset, or tenant context is unclear, clarify it before running the create command.', ], + reportingGuidance: [ + 'After create succeeds, use the returned workspace name and URLs as the source of truth when reporting the result back to the caller.', + ], missingOwnerInputGuidance: [ 'owner input is required.', 'Use --owner-provider and --owner-subject when you only know a messaging-platform user ID.', @@ -424,6 +428,11 @@ const audienceGuidanceByAudience: Record = { 'If provider, subject, preset, or tenant context is unclear, ask for clarification instead of guessing.', 'If external owner resolution fails, tell the caller the user needs to connect their account, then retry with the same platform user ID.', ], + reportingGuidance: [ + 'When replying in a messaging app, tag the person who requested the workspace.', + 'Say what was created and how to open it, using the returned workspace name and URLs from the create response.', + 'Do not construct workspace URLs yourself.', + ], missingOwnerInputGuidance: [ 'owner input is required.', 'If this request came from a messaging app, use the platform-native user ID with --owner-provider and --owner-subject.', @@ -448,6 +457,7 @@ function renderBullets(lines: string[]): string { function createUsage(guidance = guidanceForAudience()) { const ownerNotes = `Ownership guidance:\n${renderBullets(guidance.createOwnershipGuidance)}\n`; + const reportingNotes = `Reporting guidance:\n${renderBullets(guidance.reportingGuidance)}\n`; console.log(`Spritz create @@ -461,7 +471,8 @@ Examples: spritz create --preset claude-code --owner-provider discord --owner-subject 123456789012345678 --source discord --request-id discord-123 --idempotency-key discord-123 --json spritz create --preset openclaw --owner-id user-123 --idempotency-key req-123 --json -${ownerNotes}`); +${ownerNotes} +${reportingNotes}`); } function usage(guidance = guidanceForAudience()) { diff --git a/cli/test/help.test.ts b/cli/test/help.test.ts index fe5f752..f31e78f 100644 --- a/cli/test/help.test.ts +++ b/cli/test/help.test.ts @@ -43,4 +43,6 @@ test('create help for agent audience prefers external owner guidance', async () assert.match(result.stdout, /Never pass a messaging-platform user ID through --owner-id/i); assert.match(result.stdout, /connect their account/i); assert.match(result.stdout, /ask for\s+clarification instead of guessing/i); + assert.match(result.stdout, /tag the person who requested the workspace/i); + assert.match(result.stdout, /what was created and how to open it/i); }); diff --git a/cli/test/skillflag.test.ts b/cli/test/skillflag.test.ts index f4752d7..0aa64a4 100644 --- a/cli/test/skillflag.test.ts +++ b/cli/test/skillflag.test.ts @@ -43,4 +43,6 @@ test('skillflag show returns the bundled spz skill body', async () => { assert.match(result.stdout, /never pass a Discord, Slack, or Teams user ID through `--owner-id`/i); assert.match(result.stdout, /connect their account/i); assert.match(result.stdout, /ask for\s+clarification instead of guessing/i); + assert.match(result.stdout, /tag the person\s+who requested it/i); + assert.match(result.stdout, /include what was created plus the returned URLs\s+for\s+opening it/i); });