Feature Proposal: Multi-Target Operations
- Dominant language
- TypeScript
- Stars
- 283
- Forks
- 95
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 183
Description
# Multi-Target Operations: CLI DevEx Proposal
**Author:** gitikavj@
**Date:** 2026-05-08
**Status:** Draft
**Archetype:** scope_widening
---
## What is Multi-Target Operations?
Today, `aws-targets.json` supports an array of named deployment targets (e.g., dev, staging, prod), but the CLI hardcodes resolution to the first entry for most operational commands. Developers who need to work across multiple environments must either manually swap entries or maintain separate project directories — neither is acceptable at scale.
Multi-Target Operations makes the target selection surface first-class across all operational commands. Every command that reads or writes to a deployed environment gains `--target ` and `--all-targets` flags. The default behavior (no flag) uses the first configured target, preserving full backwards compatibility for single-target projects.
The core gap is narrow and surgical: `resolveAgent()` in `src/cli/operations/resolve-agent.ts` hardcodes `targetNames[0]`. That one callsite, plus consistent flag exposure across `logs` and `traces` (the two commands currently missing `--target`), is the bulk of the work. The multi-target state model in `deployed-state.json` already supports this — it has been per-target since day one.
---
## Scope
**In scope:**
- Adding `--target ` and `--all-targets` flags to `logs` and `traces` (the two commands that currently lack them)
- Normalizing `--target` default behavior across `deploy`, `status`, and `invoke` (currently inconsistent)
- Adding `--all-targets` flag to `deploy`, `status`, `invoke`, `logs`, `traces`
- Adding `--parallel` execution flag for `--all-targets` operations (power-user opt-in)
- Adding `--target` and `--all-targets` to `fetch access` command
- Fixing `resolveAgent()` to accept an explicit target name instead of hardcoding `targetNames[0]`
- Schema normalization in `aws-targets.json` (already supports named targets — no structural change needed)
**Out of scope:**
- Console/UI experience
- Service internals or control plane changes
- Changes to `aws-targets.json` format (already correct)
- Changes to `deployed-state.json` structure (already per-target)
- Target creation/management commands (`add target`, `remove target`) — covered by direct `aws-targets.json` editing today
---
## Current State
`aws-targets.json` is an array of `AwsDeploymentTarget` objects (defined in `src/schema/schemas/aws-targets.ts`), each with `name`, `account`, `region`, and optional `description`. The structure has always supported multiple targets. `deployed-state.json` (defined in `src/schema/schemas/deployed-state.ts`) stores state as `Record` — also per-target since inception.
The gap is entirely in the command layer. `resolveAgent()` (`src/cli/operations/resolve-agent.ts`) always uses `targetNames[0]` from deployed state. Of the six operational commands, three have `--target` flags but with inconsistent defaults, and two (`logs`, `traces`) have no `--target` flag at all.
**Key files today:**
- `src/cli/operations/resolve-agent.ts` — shared agent resolution; hardcodes first target
- `src/cli/commands/deploy/command.tsx` — has `--target`, defaults to literal `"default"`
- `src/cli/commands/deploy/actions.ts` — deploy business logic
- `src/cli/commands/status/command.tsx` — has `--target`, defaults to first deployed target
- `src/cli/commands/status/action.ts` — status business logic
- `src/cli/commands/invoke/command.tsx` — has `--target`, defaults to literal `"default"`
- `src/cli/commands/invoke/action.ts` — invoke business logic
- `src/cli/commands/logs/command.tsx` — **no `--target` flag**; uses resolveAgent() → first target
- `src/cli/commands/logs/action.ts` — logs business logic
- `src/cli/commands/traces/command.tsx` — **no `--target` flag**; uses resolveAgent() → first target
- `src/cli/commands/traces/action.ts` — traces business logic
- `src/schema/schemas/aws-targets.ts` — AwsDeploymentTarget Zod schema
- `src/schema/schemas/deployed-state.ts` — DeployedState Zod schema (already per-target)
- `src/lib/schemas/io/config-io.ts` — resolveAWSDeploymentTargets() with region fallback logic
**Current developer experience:**
```bash
# Single target (works fine):
agentcore deploy # deploys to "default" target
# Multi-target (broken — user must edit aws-targets.json):
agentcore deploy --target prod # works on deploy only
agentcore logs # always shows first target's logs — no flag to change this
agentcore traces # always shows first target's traces — no flag to change this
```
**Current --target flag audit:**
| Command | Has `--target`? | Default | Notes |
|--------------|-----------------|---------------------|-----------------------------------------|
| `deploy` | Yes | `"default"` (literal) | Fails if no target named "default" |
| `status` | Yes | First deployed target | Most sensible default |
| `invoke` | Yes | `"default"` (literal) | Falls back to first deployed target |
| `logs` | No | First target via resolveAgent() | Gap |
| `traces` | No | First target via resolveAgent() | Gap |
| `fetch access` | No | N/A | Gap |
---
## Target State
Every operational command that touches a deployed environment exposes `--target ` and `--all-targets`. The default with no flag is the first entry in `aws-targets.json` (not the literal string `"default"`). With `--all-targets`, the command runs against every configured target sequentially, with `--parallel` available for concurrent execution.
**Target developer experience:**
```bash
# Single target (explicit):
agentcore deploy --target prod
agentcore status --target staging
agentcore invoke --target dev "hello world"
agentcore logs --target prod
agentcore traces --target prod
agentcore fetch access --target prod
# All targets (sequential by default):
agentcore deploy --all-targets
agentcore status --all-targets
agentcore invoke --all-targets "hello world"
# All targets (parallel — power users):
agentcore deploy --all-targets --parallel
agentcore status --all-targets --parallel
# Default (unchanged behavior — uses first target):
agentcore deploy
agentcore status
agentcore invoke "hello world"
agentcore logs
agentcore traces
```
---
## Coexistence Model
**Model:** `user_chooses_one`
**Backwards compatible:** Yes
| Dimension | Existing (single-target) | New (multi-target) |
|-----------------|--------------------------|---------------------------------------------|
| Triggered by | No flag (implicit) | `--target ` or `--all-targets` |
| Configuration | aws-targets.json entry 0 | Explicit name or all entries |
| Deploy mechanism | CDK/imperative (unchanged) | Same — dispatches per-target |
| State tracking | deployed-state.json targets[first] | deployed-state.json targets[name] |
Projects with a single entry in `aws-targets.json` are unaffected. `--target` defaults to the first configured target. No migration required. No config field changes.
**Callout:** The default-to-first-entry rule replaces the current default-to-literal-`"default"` behavior in `deploy` and `invoke`. This is a bug fix, not a breaking change — any project whose first target is named `"default"` (the common case) sees identical behavior.
---
## Developer Journeys
### 1/ Developer with multiple targets runs deploy to prod
**User story:** A developer has `aws-targets.json` with `[dev, staging, prod]` entries. They want to ship to prod only.
**CLI experience:**
```bash
agentcore deploy --target prod
```
**Under the hood:**
1. `handleDeploy()` receives `options.target = "prod"`
2. `resolveAWSDeploymentTargets()` reads `aws-targets.json` — finds 3 entries
3. Validates `"prod"` exists in targets — error with list of valid names if not
4. Deploys CDK stack for `prod` target only
5. Writes `deployed-state.json` at `targets.prod`
**What we build:**
- [ ] No new code for deploy — `--target` already exists; behavior is correct
---
### 2/ Developer deploys to all targets in sequence
**User story:** Same developer wants to promote a release to all three environments in one command.
**CLI experience:**
```bash
agentcore deploy --all-targets
# Output:
# [1/3] Deploying to dev... ✓ (12s)
# [2/3] Deploying to staging... ✓ (14s)
# [3/3] Deploying to prod... ✓ (11s)
```
**Under the hood:**
1. `handleDeploy()` receives `options.allTargets = true`
2. Reads all entries from `aws-targets.json`
3. Iterates sequentially (or in parallel if `--parallel`)
4. Calls existing single-target deploy logic per entry
5. Aggregates results; prints per-target status
6. Exits non-zero if any target fails; reports which targets failed
**What we build:**
- [ ] `--all-targets` flag on `deploy`, `status`, `invoke`, `logs`, `traces`, `fetch access`
- [ ] `--parallel` flag (companion to `--all-targets`)
- [ ] `runForAllTargets()` utility in `src/cli/operations/multi-target.ts`
- [ ] Per-target result aggregation and exit code logic
---
### 3/ Developer checks logs for a specific target
**User story:** Developer wants to tail logs for the prod deployment of their agent.
**CLI experience:**
```bash
agentcore logs --target prod
agentcore logs --target prod --runtime my-agent
```
**Under the hood:**
1. `logs` command receives `options.targetName = "prod"`
2. Passes `targetName` to `resolveAgent()` instead of relying on `targetNames[0]`
3. `resolveAgent()` uses the explicit `targetName` to look up state in `deployed-state.json`
4. CloudWatch logs fetched for the resolved runtime in the prod region
**What we build:**
- [ ] `--target ` flag on `logs` command (`src/cli/commands/logs/command.tsx`)
- [ ] `--target ` flag on `traces` command (`src/cli/commands/traces/command.tsx`)
- [ ] `resolveAgent()` accepts optional `targetName` param; uses it when provided
---
### 4/ Existing single-target project — unchanged (proves backwards compat)
**User story:** Developer has one entry in `aws-targets.json` named `"default"`. They never use `--target`.
**CLI experience:**
```bash
# Everything works exactly as before:
agentcore deploy # uses first (and only) target: "default"
agentcore status # uses first deployed target: "default"
agentcore invoke "hello" # uses first deployed target: "default"
agentcore logs # uses first deployed target: "default"
agentcore traces # uses first deployed target: "default"
```
**Under the hood:**
1. No `--target` or `--all-targets` flag provided
2. Code reads `aws-targets.json[0]` as the target (same as today for `status`; fixes `deploy`/`invoke` default bug)
3. All existing code paths invoked identically
**Callout:** This is the regression test. If this journey breaks after the change ships, it's a blocker.
---
## Affected Commands
| Command | Change Type | Description | Backwards Compatible |
|-----------------------|------------------|---------------------------------------------------------------------------------------|----------------------|
| `agentcore deploy` | `new_flag` | Add `--all-targets` and `--parallel`; normalize default to first target (not `"default"`) | Yes |
| `agentcore status` | `new_flag` | Add `--all-targets` and `--parallel` | Yes |
| `agentcore invoke` | `new_flag` | Add `--all-targets` and `--parallel`; normalize default to first target (not `"default"`) | Yes |
| `agentcore logs` | `new_flag` | Add `--target `, `--all-targets`, `--parallel` | Yes |
| `agentcore traces` | `new_flag` | Add `--target `, `--all-targets`, `--parallel` | Yes |
| `agentcore fetch access` | `new_flag` | Add `--target `, `--all-targets` | Yes |
---
## Schema Changes
No changes to `agentcore.json` or `deployed-state.json` schema. Both already support multiple targets. No new fields.
**Callout:** The only "schema" change is behavioral: the unset-`--target` default moves from the literal string `"default"` to `awsTargets[0].name`. This is enforced in command option parsing, not schema validation.
### In `aws-targets.json` (no structural change):
```json
[
{ "name": "dev", "account": "111122223333", "region": "us-west-2" },
{ "name": "staging", "account": "444455556666", "region": "us-west-2" },
{ "name": "prod", "account": "777788889999", "region": "us-east-1" }
]
```
### In `deployed-state.json` (no structural change):
```json
{
"targets": {
"dev": { "resources": { "runtimes": { "my-agent": { "runtimeId": "..." } } } },
"staging": { "resources": { "runtimes": { "my-agent": { "runtimeId": "..." } } } },
"prod": { "resources": { "runtimes": { "my-agent": { "runtimeId": "..." } } } }
}
}
```
---
## Detection & Prerequisites
No external tool dependencies. No detection logic needed.
**Target validation error messages:**
| Scenario | Message |
|------------------------------------|-----------------------------------------------------------------------------------|
| `--target` name not in aws-targets.json | `Target "prod" not found. Available targets: dev, staging (from aws-targets.json)` |
| `--all-targets` with empty aws-targets.json | `No targets configured. Add entries to aws-targets.json first.` |
| `--target` + `--all-targets` together | `--target and --all-targets are mutually exclusive.` |
| `--parallel` without `--all-targets` | `--parallel requires --all-targets.` |
---
## Codebase Changes
### New Files
| File | Purpose |
|---------------------------------------------------|--------------------------------------------------|
| `src/cli/operations/multi-target.ts` | `runForAllTargets()` utility — sequential/parallel loop with result aggregation |
### Modified Files
| File | Change |
|---------------------------------------------------|-------------------------------------------------------------------------------------|
| `src/cli/operations/resolve-agent.ts` | Accept optional `targetName?: string` param; use it instead of `targetNames[0]` when provided |
| `src/cli/commands/deploy/command.tsx` | Normalize default: `awsTargets[0].name` instead of `"default"`; add `--all-targets`, `--parallel` |
| `src/cli/commands/deploy/actions.ts` | Thread target through multi-target loop when `--all-targets` |
| `src/cli/commands/status/command.tsx` | Add `--all-targets`, `--parallel` |
| `src/cli/commands/status/action.ts` | Thread target through multi-target loop when `--all-targets` |
| `src/cli/commands/invoke/command.tsx` | Normalize default; add `--all-targets`, `--parallel` |
| `src/cli/commands/invoke/action.ts` | Thread target through multi-target loop; pass `targetName` to resolveAgent() |
| `src/cli/commands/logs/command.tsx` | Add `--target `, `--all-targets`, `--parallel` |
| `src/cli/commands/logs/action.ts` | Pass `targetName` to resolveAgent() |
| `src/cli/commands/traces/command.tsx` | Add `--target `, `--all-targets`, `--parallel` |
| `src/cli/commands/traces/action.ts` | Pass `targetName` to resolveAgent() |
| `src/cli/commands/fetch-access/command.tsx` | Add `--target `, `--all-targets` |
| `src/cli/commands/fetch-access/action.ts` | Thread target name through resolution |
---
## Architecture: Where It Branches
### Dispatch Point
```
agentcore [command] [args]
│
├─ --target → resolves single named target → existing single-target path
├─ --all-targets → runForAllTargets() → iterates aws-targets.json entries
│ └─ [--parallel] → Promise.all vs sequential loop
└─ (no flag) → awsTargets[0] → existing single-target path (unchanged behavior)
```
**Pattern:** Dispatch at command option parsing — no Strategy class needed. `runForAllTargets()` is a thin iterator that calls the existing single-target action function per entry.
**Precedent in the codebase:** `src/cli/commands/status/action.ts` already implements the dispatch pattern — it reads `deployedTargetNames`, selects `options.targetName ?? targetNames[0]`, and passes the name down. The `deploy` and `invoke` normalization fix copies this exact pattern.
### Deploy Flow Change
No change to the deploy flow phases. Multi-target with `--all-targets` is the existing flow executed N times in sequence (or parallel). The CDK path, preflight, state write — all unchanged per invocation.
```
Current: New (--all-targets):
1. Preflight validation FOR EACH target in aws-targets.json:
2. Identity providers 1. Preflight validation
3. OAuth2 providers 2. Identity providers
4. CDK synth + deploy 3. OAuth2 providers
5. Parse outputs → deployed-state.json 4. CDK synth + deploy
6. Post-deploy operations 5. Parse outputs → deployed-state.json
6. Post-deploy operations
Report: 3/3 succeeded | 1/3 failed (prod)
```
---
## Architectural Decisions
| # | Decision | Choice | Rationale |
|----|---------------------------------|---------------------------------------------|------------------------------------------------------------------------------------|
| 1 | Where to fix resolveAgent() | Add optional `targetName` param | Backwards compatible — existing callers pass nothing, get current behavior |
| 2 | Default target resolution | `awsTargets[0].name` (not `"default"`) | Matches status command's already-correct behavior; fixes latent bug in deploy/invoke |
| 3 | --all-targets execution model | Sequential default, `--parallel` opt-in | Sequential is safer for deploy (CDK limits, IAM propagation); parallel useful for status/logs |
| 4 | Multi-target utility location | `src/cli/operations/multi-target.ts` | Consistent with existing operations directory; keeps command files thin |
| 5 | --target + --all-targets mutual exclusivity | Error at flag parse time | Unambiguous intent; avoids silent override of one by the other |
| 6 | Schema changes | None | deployed-state.json and aws-targets.json already model multi-target correctly |
---
## Implementation Phases
### Phase 1: Fix resolveAgent() and normalize defaults (independently shippable)
- [ ] Add `targetName?: string` param to `resolveAgent()` in `src/cli/operations/resolve-agent.ts`
- [ ] When `targetName` provided: use it to look up state; error clearly if not found
- [ ] When `targetName` absent: preserve current behavior (`targetNames[0]`) — no behavior change
- [ ] Normalize `deploy` and `invoke` default: replace `"default"` literal with `awsTargets[0].name`
- [ ] Unit tests: resolveAgent with explicit target, resolveAgent with absent target, resolveAgent default
- [ ] **Ship note:** After this lands, zero behavior change for single-target projects. Bug fix for projects whose first target isn't named `"default"`.
### Phase 2: Add --target to logs, traces, fetch access (independently shippable)
- [ ] Add `--target ` to `logs` command (`command.tsx` + pass to `action.ts`)
- [ ] Add `--target ` to `traces` command (`command.tsx` + pass to `action.ts`)
- [ ] Add `--target ` to `fetch access` command (`command.tsx` + pass to `action.ts`)
- [ ] Thread `targetName` through to `resolveAgent()` in all three action files
- [ ] Unit tests: logs with explicit target, traces with explicit target, invalid target error
- [ ] **Ship note:** After this lands, `logs`, `traces`, and `fetch access` behave consistently with `deploy`, `status`, `invoke`.
### Phase 3: Add --all-targets and --parallel (independently shippable)
- [ ] Write `runForAllTargets()` in `src/cli/operations/multi-target.ts`
- Reads `resolveAWSDeploymentTargets()` to get ordered target list
- Accepts action function, options, and `parallel: boolean`
- Sequential: for-await loop; Parallel: Promise.allSettled
- Returns per-target results with success/failure and output
- [ ] Add `--all-targets` and `--parallel` flags to all six commands
- [ ] Implement mutual exclusivity validation (`--target` + `--all-targets` → error)
- [ ] Implement `--parallel` without `--all-targets` → error
- [ ] Per-target progress output: `[1/3] Deploying to dev... ✓ (12s)`
- [ ] Aggregate exit code: non-zero if any target fails
- [ ] Unit tests: sequential loop, parallel loop, partial failure, mutual exclusivity errors
- [ ] **Ship note:** After this lands, all multi-target workflows are available.
### Phase 4: Polish (independently shippable)
- [ ] Update help text for all six commands to document new flags
- [ ] Update `docs/commands.md` and `docs/configuration.md`
- [ ] Add E2E test: deploy to two targets in sequence; verify both appear in deployed-state.json
- [ ] Edge cases: `--all-targets` with one target (degenerate case — should work identically to `--target first`)
- [ ] **Ship note:** Documentation and E2E coverage for the complete feature.
---
## Testing Strategy
### Existing Test Preservation
All existing tests must pass without modification after Phases 1-3. The changes to `resolveAgent()` are purely additive (optional param). The normalize-default fix only breaks projects with a misconfigured first target named something other than `"default"` — which is the target of the fix. Run `npm test`, `npm run typecheck`, `npm run lint` on every PR.
**Precedent in the codebase:** `src/cli/commands/status/action.ts` implements target selection with `options.targetName ?? targetNames[0]`. Existing status tests already cover the no-flag path. We verify that pattern still passes after we extend it to the other commands.
### New Test Matrix
| Scenario | No flag (default) | `--target prod` | `--all-targets` | `--all-targets --parallel` |
|---------------------------------|-------------------|-----------------|-----------------|---------------------------|
| deploy | ✓ (first target) | ✓ | ✓ | ✓ |
| status | ✓ (first target) | ✓ | ✓ | ✓ |
| invoke | ✓ (first target) | ✓ | ✓ | ✓ |
| logs | ✓ (first target) | ✓ | ✓ | ✓ |
| traces | ✓ (first target) | ✓ | ✓ | ✓ |
| fetch access | ✓ (first target) | ✓ | N/A | N/A |
| Invalid `--target` name | N/A | Error + list | N/A | N/A |
| `--target` + `--all-targets` | N/A | Error | N/A | N/A |
| `--parallel` without `--all-targets` | N/A | Error | N/A | N/A |
| Partial failure (one target fails) | N/A | N/A | Non-zero exit | Non-zero exit |
### Integration / E2E
- Against account `998846730471` in `us-west-2` and `us-east-1`:
- Deploy two-target project; verify `deployed-state.json` has both target entries
- `agentcore status --all-targets` shows both target states
- `agentcore logs --target ` streams correct CloudWatch log group
- `agentcore deploy --target ` does not touch the other target's stack
---
## Open Questions
| # | Question | For | Context |
|----|------------------------------------------------------------------------------|------------------|-----------------------------------------------------------------------------------------------|
| 1 | Should `--all-targets` on `invoke` be supported? It's operationally unusual to invoke an agent on every target at once. | gitikavj@ / PM | Included in scope per inputs, but worth a deliberate decision. Power-user edge case only. |
| 2 | For `--all-targets --parallel` on `deploy`: should there be a max-concurrency cap, or unlimited? | gitikavj@ | Unlimited could hit CDK rate limits or IAM propagation races if many targets exist. |
| 3 | Should failed targets in `--all-targets` be retried, or fail-fast? | gitikavj@ / Eng | Current proposal: no retry, non-zero exit with which targets failed. |
| 4 | Does `fetch access` support `--all-targets`? The inputs include it, but access tokens are per-target by definition. | gitikavj@ | Included per inputs. Need confirmation that listing all-target tokens is a real use case. |
---
## Escalation Required
- **Default target string `"default"` vs first-entry:** Changing the default from the literal string `"default"` to `awsTargets[0].name` is my recommendation, but it technically changes behavior for projects where `awsTargets[0].name !== "default"`. This is a latent bug fix, but the AgentCore CLI team should sign off before Phase 1 ships.
- **`--parallel` on `deploy`:** Parallel CDK deployments to multiple targets in the same account/region may hit CloudFormation stack operation limits. Need confirmation from the CDK/infra team on whether throttling is a risk here, and whether a max-concurrency cap is needed.
---
## Appendix
### Side-by-Side: Single-Target vs Multi-Target Project
**Single-target project (unchanged):**
`aws-targets.json`:
```json
[
{ "name": "default", "account": "998846730471", "region": "us-west-2" }
]
```
```bash
agentcore deploy # same as today
agentcore status # same as today
agentcore logs # same as today — resolves to "default"
```
**Multi-target project (new capability):**
`aws-targets.json`:
```json
[
{ "name": "dev", "account": "111122223333", "region": "us-west-2" },
{ "name": "staging", "account": "444455556666", "region": "us-west-2" },
{ "name": "prod", "account": "777788889999", "region": "us-east-1" }
]
```
```bash
agentcore deploy # deploys to dev (first entry)
agentcore deploy --target prod # deploys to prod only
agentcore deploy --all-targets # deploys dev → staging → prod in sequence
agentcore status --all-targets # shows status for all three
agentcore logs --target prod # streams prod logs
```
### resolveAgent() Change (Sketch)
**Before:**
```typescript
// src/cli/operations/resolve-agent.ts
const targetName = targetNames[0]; // always first
```
**After:**
```typescript
// src/cli/operations/resolve-agent.ts
function resolveAgent(
context: DeployedProjectConfig,
options: { runtime?: string; targetName?: string } // targetName added
) {
const targetName = options.targetName ?? targetNames[0]; // explicit wins
// ... rest unchanged
}
```
### runForAllTargets() Sketch
```typescript
// src/cli/operations/multi-target.ts
export async function runForAllTargets(
targets: AwsDeploymentTarget[],
action: (target: AwsDeploymentTarget) => Promise,
options: { parallel?: boolean }
): Promise> {
if (options.parallel) {
const results = await Promise.allSettled(targets.map(action));
return targets.map((t, i) => ({
target: t.name,
result: results[i].status === 'fulfilled' ? results[i].value : results[i].reason,
}));
}
const results = [];
for (const target of targets) {
try {
results.push({ target: target.name, result: await action(target) });
} catch (err) {
results.push({ target: target.name, result: err as Error });
}
}
return results;
}
```
Contributor guide
Assessment
This issue has not been assessed yet.