Skip to content

Feature Request: call-workflow safe output for workflow_call chaining #20411

@mvdbos

Description

@mvdbos

Feature Request: call-workflow safe output for workflow_call chaining

Summary

Request for a new call-workflow safe output type that enables an engine: copilot (gh-aw) gateway workflow to chain to a worker workflow via workflow_call. This would enable the orchestrator/dispatcher pattern to work within workflow_call chains — without requiring additional tokens, without changing the security model, and without any of the billing/identity/secrets problems caused by repository_dispatch.


What we're building

An internal developer platform that provides standardised, opinionated coding agent workflows to application teams across the enterprise. The platform is hosted in a single central repository and serves multiple consumer teams.

The platform provides:

  • Gateway: an engine: copilot (gh-aw) workflow that receives triggers, runs preflight checks, and dispatches to the appropriate agentic workflow for the task.
  • Workers: specialised engine: copilot (gh-aw) workflows — one per application archetype and task combination (e.g., spring-boot/bug-fix, frontend/dep-upgrade). Each worker runs a coding agent session to execute the task, validates its own output via post-steps:, and creates a pull request via the create-pull-request safe output.
  • Custom agents: agent instruction files imported via imports: at compile time.
  • Quality gates: deterministic and LLM-based checks at input (gateway) and output (worker post-steps:, scope review).

Consumer teams provide:

  • Their repository (the codebase the agent works on).
  • An issue describing the work.
  • A trigger (slash command on the issue).

The platform must support thousands of potential users across hundreds of teams. Costs (premium requests, action minutes) should be attributed to the team member triggering the platform, not to the platform.

The orchestrator/dispatcher pattern

The platform uses an orchestrator/dispatcher pattern: the gateway determines which worker to run, then chains to the appropriate worker. This gateway → worker chaining is essential — it's how the platform routes different task types to specialised agentic workflows.

Today, the only chaining mechanism in gh-aw is dispatch-workflow, which uses workflow_dispatch and is same-repository only. This is the fundamental constraint that motivates this feature request.

Why workflow_call matters for us

We want consumer teams to invoke the platform via cross-repo workflow_call (a thin caller workflow in the consumer repo using uses: org/platform/.github/workflows/gateway.lock.yml@main with secrets: inherit). This gives us:

  • Correct billing: Actions minutes billed to the consumer, not the platform.
  • Correct identity: github.actor reflects the developer who triggered the workflow.
  • Secret flow: Consumer's COPILOT_GITHUB_TOKEN flows via secrets: inherit.
  • Central control: Platform team maintains all workflow logic in one repo.

gh-aw recently added explicit support for cross-repo workflow_call in PR #20301, confirming this direction is aligned with the platform's trajectory.

The only missing piece is gateway → worker chaining within a workflow_call context.


Why dispatch-workflow doesn't work here

dispatch-workflow uses the GitHub Actions workflow_dispatch REST API at runtime. gh-aw's documentation explicitly restricts this to same-repository:

Same-repository only - Cannot dispatch workflows in other repositories. This prevents cross-repository workflow triggering which could be a security risk.

This restriction has genuine validity for runtime API dispatch:

Concern Detail
Compute, not just data Unlike create-issue or add-comment (which already support cross-repo), dispatching a workflow causes code to run with whatever permissions the target workflow declares.
Token management Cross-repo dispatch requires a token with actions: write on the target repo. GITHUB_TOKEN in workflow_call context is scoped to the caller.
Billing reversal workflow_dispatch minutes are billed to the repo where the workflow runs (the target), not the caller.
Identity loss github.actor in a workflow_dispatch run is the token owner that called the API, not the original developer.

Even if we made dispatch-workflow cross-repo, it would undo the billing, identity, and secret-flow benefits that workflow_call provides.


Proposed solution: compile-time workflow_call fan-out with runtime selection

The key insight

workflow_call is not an API call — it's a YAML uses: declaration resolved by the GitHub Actions scheduler before any code runs. Crucially, uses: cannot contain expressions — it must be a literal string. This is a GitHub Actions platform constraint. An agent cannot dynamically trigger workflow_call at runtime.

But the gh-aw compiler can generate uses: declarations at compile time, with runtime conditionals that let the gateway agent select which worker to activate. This is the only possible approach for workflow_call chaining given the platform constraint.

How the compiled workflow would look

The compiled .lock.yml follows gh-aw's standard job pipeline: activationagentsafe_outputs. The call-workflow conditional jobs come after safe_outputs, which is the job that processes the agent's output and sets the call_workflow_name output variable.

Gateway workflow (compiled .lock.yml)
├── activation job (setup, sanitisation, prompt generation)
├── agent job (AI engine runs, selects worker via MCP tool)
├── safe_outputs job (processes agent output, sets call_workflow_name + payload)
├── worker-A job (if: selected == 'A')  →  uses: ./worker-A.lock.yml
├── worker-B job (if: selected == 'B')  →  uses: ./worker-B.lock.yml
└── worker-C job (if: selected == 'C')  →  uses: ./worker-C.lock.yml

All worker references are resolved and validated at compile time. The agent's only runtime decision is which pre-validated worker to activate. Only one conditional job runs per execution; the rest are skipped. GitHub Actions handles skipped jobs efficiently — they don't consume minutes or runner slots.

What the markdown workflow would look like

---
on:
  workflow_call:
    inputs:
      issue_number:
        required: true
        type: number

engine: copilot

safe-outputs:
  call-workflow:
    workflows:
      - spring-boot-bugfix
      - frontend-dep-upgrade
      - python-migration
    max: 1
---

# Gateway

Analyse the issue and the target repository.
Determine which worker workflow to dispatch.

How the agent selects a worker (per-worker MCP tools)

This is how dispatch-workflow already works, and call-workflow would reuse the same pattern.

At compile time, the compiler reads each allowed worker's frontmatter, extracts its declared inputs, and generates a separate, named MCP tool per worker — with a schema that describes that worker's specific parameters. For example, given these two workers:

spring-boot-bugfix.md declares inputs environment (choice: dev/staging/production) and version (string).
frontend-dep-upgrade.md declares inputs package_manager (choice: npm/yarn/pnpm) and dry_run (boolean).

The compiler generates two MCP tools that the agent sees during its session:

  • spring_boot_bugfix(environment, version) — with enum constraints, descriptions, required flags
  • frontend_dep_upgrade(package_manager, dry_run) — with typed parameters

The agent doesn't see a generic "call some workflow" tool. It sees rich, named, typed tools — identical to how dispatch-workflow works today. The code that does this already exists:

  • generateDispatchWorkflowTool() in pkg/workflow/safe_outputs_dispatch.go reads each worker's inputs and generates the MCP tool definition
  • extractWorkflowDispatchInputs() in pkg/workflow/dispatch_workflow_validation.go extracts the input schema from each worker's frontmatter
  • The runtime handler in actions/setup/js/safe_outputs_tools_loader.cjs maps per-workflow tool calls to the generic handler

When the agent calls spring_boot_bugfix(environment: "staging", version: "1.2.3"), the handler sets output variables instead of making an API call:

call_workflow_name   = "spring-boot-bugfix"
call_workflow_payload = '{"environment":"staging","version":"1.2.3"}'

The conditional uses: job for spring-boot-bugfix evaluates its if: condition, finds a match, and runs.

Input forwarding: the JSON envelope pattern

A key design decision is how to forward the agent's inputs to the selected worker. We considered two approaches:

Approach How it works Problem
Per-worker typed inputs Compiler reads each worker's workflow_call.inputs, exposes each as a separate output from safe_outputs, generates a different with: block per conditional job The union of all inputs across all workers must be exposed as individual safe_outputs outputs. Hits GitHub's 50 total inputs+secrets+outputs limit per workflow_call. Compiler must generate a different with: block for every conditional job.
Single JSON payload All workers declare one input (payload: { type: string }). Agent output is serialised as JSON. All conditional jobs pass the same payload output. Workers must parse JSON internally. Less type-safe at the YAML level. But mirrors how dispatch-workflow already works — all workflow_dispatch input values are stringified at the API level.

We recommend the JSON envelope pattern. It's simpler, doesn't hit platform limits, doesn't require the compiler to understand each worker's input schema for the with: block, and matches the existing dispatch-workflow architecture. The per-worker MCP tools still give the agent a rich, typed interface — the JSON envelope is just the plumbing underneath.

Ref pinning: not needed

Since the gateway and workers are in the same repo, the compiler generates relative paths:

uses: ./.github/workflows/worker-a.lock.yml

When a consumer calls the gateway via uses: org/platform/.github/workflows/gateway.lock.yml@main, GitHub resolves the gateway's internal uses: ./... references against the platform repo at the same ref. No explicit ref pinning or SHA resolution is needed.

What the compiler would generate

# Compiled gateway.lock.yml (simplified)
jobs:
  activation:
    # ... standard activation job ...

  agent:
    needs: [activation]
    # ... standard agent job ...
    # Agent calls the "spring_boot_bugfix" MCP tool
    # Agent output: { "type": "call_workflow", "workflow_name": "spring-boot-bugfix",
    #                 "inputs": {"environment":"staging","version":"1.2.3"} }

  safe_outputs:
    needs: [agent]
    if: ((!cancelled()) && (needs.agent.result != 'skipped'))
    runs-on: ubuntu-latest
    outputs:
      call_workflow_name: ${{ steps.process_safe_outputs.outputs.call_workflow_name }}
      call_workflow_payload: ${{ steps.process_safe_outputs.outputs.call_workflow_payload }}
    steps:
      - name: Process Safe Outputs
        id: process_safe_outputs
        # Validates workflow_name against allowlist
        # Serialises inputs as JSON payload string

  # Conditional workflow_call jobs — one per allowed worker
  call-spring-boot-bugfix:
    needs: [safe_outputs]
    if: needs.safe_outputs.outputs.call_workflow_name == 'spring-boot-bugfix'
    uses: ./.github/workflows/spring-boot-bugfix.lock.yml
    secrets: inherit
    with:
      payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}

  call-frontend-dep-upgrade:
    needs: [safe_outputs]
    if: needs.safe_outputs.outputs.call_workflow_name == 'frontend-dep-upgrade'
    uses: ./.github/workflows/frontend-dep-upgrade.lock.yml
    secrets: inherit
    with:
      payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}

  call-python-migration:
    needs: [safe_outputs]
    if: needs.safe_outputs.outputs.call_workflow_name == 'python-migration'
    uses: ./.github/workflows/python-migration.lock.yml
    secrets: inherit
    with:
      payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}

  conclusion:
    needs: [activation, agent, safe_outputs,
            call-spring-boot-bugfix, call-frontend-dep-upgrade, call-python-migration]
    if: always()
    # ... standard conclusion job, checks which worker ran ...

What this approach preserves

Aspect Detail
Security model Identical to existing workflow_call. No new trust boundaries. No cross-repo API calls.
No additional tokens secrets: inherit flows GITHUB_TOKEN and all caller secrets through the chain. No PATs, no App tokens.
Billing Minutes billed to the original caller (consumer repo), following workflow_call billing rules.
Identity github.actor preserved through the workflow_call chain.
Allowlist enforcement Only workflows in the workflows list can be selected. Compile-time validated.
Agent experience Identical to dispatch-workflow — per-worker named MCP tools with typed parameters.

Platform constraints this respects

Constraint How we handle it
uses: cannot contain expressions Static fan-out with if: conditionals — the only possible approach for workflow_call chaining.
50 inputs+secrets+outputs limit per workflow_call JSON envelope pattern: one payload input, not per-field.
4-level workflow_call nesting limit Consumer → Gateway → Worker = 2 levels. Leaves room for workers that themselves chain further.
dispatch-workflow same-repo only We don't use dispatch-workflow at all. Pure workflow_call.

Scaling considerations

Scale Assessment
≤10 workers No issues. The compiled YAML is manageable.
10–30 workers The .lock.yml becomes large but functional. Skipped jobs in the Actions UI are noisy but don't consume resources.
30+ workers Consider a tiered gateway pattern: the top-level gateway routes to category sub-gateways (each via call-workflow), and each sub-gateway routes to ~10 workers. This uses 3 levels of workflow_call nesting (consumer → gateway → sub-gateway → worker), within the platform's 4-level limit.

Implementation Plan

1. Add CallWorkflowConfig struct and parser (pkg/workflow/call_workflow.go)

Create a new file following the existing dispatch_workflow.go pattern:

  • Define CallWorkflowConfig struct with BaseSafeOutputConfig inline, Workflows []string, and WorkflowFiles map[string]string (mirroring DispatchWorkflowConfig).
  • Implement parseCallWorkflowConfig() on the Compiler — parse both array format (list of workflow names) and map format (with workflows:, max:, etc.).
  • Default max to 1. Cap at a reasonable limit (e.g., 50, matching dispatch-workflow).
  • Add CallWorkflow *CallWorkflowConfig field to SafeOutputsConfig in pkg/workflow/safe_outputs_config.go.

2. Add schema validation (pkg/parser/schemas/frontmatter.json)

Add call-workflow to the frontmatter JSON schema alongside dispatch-workflow:

  • Accept array format: call-workflow: [workflow-a, workflow-b]
  • Accept map format: call-workflow: { workflows: [...], max: 1 }
  • Validate workflows is a non-empty array of strings.

After changing the schema, run make build — schemas are embedded via //go:embed.

3. Add compile-time validation (pkg/workflow/call_workflow_validation.go)

Create a validation file following the dispatch_workflow_validation.go pattern:

  • Implement validateCallWorkflow() on the Compiler.
  • Reuse findWorkflowFile() to locate each allowed worker's .lock.yml/.yml/.md.
  • Validate each worker exists and declares workflow_call in its on: section (not just workflow_dispatch).
  • Validate self-reference prevention (gateway cannot call itself).
  • Extract each worker's workflow_call.inputs for MCP tool generation (create extractWorkflowCallInputs() mirroring extractWorkflowDispatchInputs()).
  • Wire validation into Compiler.validateWorkflow().

4. Generate per-worker MCP tools (pkg/workflow/safe_outputs_dispatch.go or new file)

Reuse the existing generateDispatchWorkflowTool() function or create a generateCallWorkflowTool() variant:

  • Read each worker's workflow_call.inputs schema.
  • Generate a named MCP tool per worker with typed parameters (string, number, boolean, choice).
  • Set _workflow_name internal metadata on each tool for handler routing.
  • Wire into safe_outputs_tools_filtering.go alongside the existing dispatch_workflow tool generation block.

5. Implement the runtime handler (actions/setup/js/call_workflow.cjs)

Create a handler that is simpler than dispatch_workflow.cjs — it makes no API calls:

  • Validate workflow_name against the compile-time allowlist.
  • Serialise inputs as a JSON string.
  • Set core.setOutput("call_workflow_name", workflowName).
  • Set core.setOutput("call_workflow_payload", JSON.stringify(inputs)).
  • Register in safe_outputs_tools_loader.cjs alongside the existing dispatch handler (the _workflow_name routing pattern already exists).

6. Generate conditional uses: jobs in the compiler (pkg/workflow/compiler_safe_output_jobs.go)

This is the core change. In buildSafeOutputsJobs():

  • After building the consolidated safe_outputs job, if data.SafeOutputs.CallWorkflow is non-nil:

    • For each workflow in CallWorkflow.Workflows, generate a conditional job:
      • name: call-{workflow-name} (sanitised)
      • needs: [safe_outputs]
      • if: needs.safe_outputs.outputs.call_workflow_name == '{workflow-name}'
      • uses: ./.github/workflows/{workflow-name}.lock.yml
      • secrets: inherit
      • with: payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
    • Add all call-* job names to the conclusion job's needs list.
  • Ensure the safe_outputs job declares call_workflow_name and call_workflow_payload as outputs.

7. Populate workflow files at compile time (pkg/workflow/safe_outputs_dispatch.go or new)

Create populateCallWorkflowFiles() mirroring populateDispatchWorkflowFiles():

  • For each workflow in the allowlist, resolve its .lock.yml file path.
  • Store in CallWorkflow.WorkflowFiles for runtime use.
  • Call from generateMCPSetup() in pkg/workflow/mcp_setup_generator.go (same pattern as populateDispatchWorkflowFiles).

8. Add unit tests

Test files must have //go:build !integration as the first line, followed by an empty line.

  • pkg/workflow/call_workflow_test.go:
    • Test parseCallWorkflowConfig() with array and map formats.
    • Test default max value and max cap.
  • pkg/workflow/call_workflow_validation_test.go:
    • Test validation of existing workers with workflow_call trigger.
    • Test self-reference prevention.
    • Test worker not found error.
    • Test worker without workflow_call trigger error.
  • pkg/workflow/safe_outputs_call_workflow_test.go:
    • Test MCP tool generation from worker workflow_call.inputs.
    • Test compiled output contains conditional uses: jobs with correct if: conditions.
    • Test secrets: inherit is present on all conditional jobs.
    • Test payload is wired correctly in with: blocks.
    • Test conclusion job depends on all call-* jobs.
  • actions/setup/js/call_workflow.test.cjs:
    • Test handler validates workflow name against allowlist.
    • Test handler serialises inputs as JSON.
    • Test handler rejects unknown workflow names.

9. Update documentation

  • docs/src/content/docs/reference/safe-outputs.md: Add call-workflow section alongside dispatch-workflow.
  • docs/src/content/docs/reference/safe-outputs-specification.md: Add formal specification.
  • docs/src/content/docs/examples/multi-repo.md: Add example of gateway → worker pattern using call-workflow.

10. Follow guidelines

  • Use console formatting from pkg/console for CLI output.
  • Follow error message style guide: [what's wrong]. [what's expected]. [example].
  • Run make agent-finish before completing (build, test, recompile, fmt, lint).
  • Run ./scripts/add-build-tags.sh to ensure all test files have correct build tags.
  • Run make lint to catch unused helpers, testifylint issues, and misspell errors.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions