Skip to content

feat(workflow)!: replace defineWorkflow<Agent>() with .for<Agent>() builder pattern for type-safe inputs - #635

Merged
lavaman131 merged 1 commit into
mainfrom
feat/typed-workflow-inputs
Apr 14, 2026
Merged

feat(workflow)!: replace defineWorkflow<Agent>() with .for<Agent>() builder pattern for type-safe inputs#635
lavaman131 merged 1 commit into
mainfrom
feat/typed-workflow-inputs

Conversation

@lavaman131

@lavaman131 lavaman131 commented Apr 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Replaces defineWorkflow<Agent>() with defineWorkflow({ inputs: [...] }).for<Agent>() so TypeScript can infer literal input names from the inputs array and enforce them on ctx.inputs at compile time. Accessing an undeclared input key is now a compile-time error.

Key Changes

  • New .for<Agent>() method on WorkflowBuilder — type-only narrowing that preserves the inferred input name literals while still constraining agent-specific stage types
  • Typed ctx.inputs — changed from Record<string, string> to { [K in N]?: string } where N is the union of declared input name literals; free-form workflows (no inputs array) continue to accept any key
  • Updated core typesWorkflowContext, SessionContext, WorkflowOptions, and WorkflowDefinition all carry the new input-name type parameter N extends string
  • Removed workflow-inputs.ts — standalone helper file consolidated into the main type definitions
  • Updated all built-in and example workflowsheadless-test, hello-world, parallel-hello-world, ralph, deep-research-codebase migrated to the new pattern
  • New tests — covers .for() identity (type-only, same instance), chaining with .run().compile(), structured input key enforcement, and free-form fallback
  • Updated docsworkflow-creator skill references, SDK TypeScript reference, and README all reflect the new API

Breaking Changes

The agent type parameter moves from defineWorkflow<Agent>() to a new .for<Agent>() chain step. Existing workflows must be updated:

- export default defineWorkflow<"copilot">({ name: "my-workflow" })
+ export default defineWorkflow({ name: "my-workflow" })
+   .for<"copilot">()
    .run(async (ctx) => { ... })
    .compile();

Inline inputs declarations now produce typed ctx.inputs:

defineWorkflow({
  name: "greet",
  inputs: [{ name: "greeting", type: "string", required: true }],
})
  .for<"copilot">()
  .run(async (ctx) => {
    ctx.inputs.greeting; // ✓ string | undefined
    ctx.inputs.prompt;   // ✗ compile error — not declared
  })
  .compile();

Replace defineWorkflow<Agent>() with defineWorkflow({ inputs: [...] }).for<Agent>()
so TypeScript can infer literal input names and enforce them on ctx.inputs at
compile time. Updates all workflows, SDK types, provider, docs, and tests.
@lavaman131
lavaman131 merged commit b794dfb into main Apr 14, 2026
4 checks passed
@lavaman131
lavaman131 deleted the feat/typed-workflow-inputs branch April 14, 2026 23:00
@claude claude Bot changed the title feat: add type-safe workflow inputs with .for<Agent>() builder pattern feat(workflow)!: replace defineWorkflow<Agent>() with .for<Agent>() builder pattern for type-safe inputs Apr 14, 2026
@claude

claude Bot commented Apr 14, 2026

Copy link
Copy Markdown

Code Review: Workflow Builder Pattern Refactor

Thanks for this substantial refactor — the new builder pattern meaningfully improves type safety. Below is constructive feedback across the requested dimensions.

Strengths

Type-safe builder patterndefineWorkflow({...}).for<Agent>() is a clean separation of concerns. Using const I extends readonly WorkflowInput[] in defineWorkflow's generic signature to preserve literal types flowing into ctx.inputs: { [K in N]?: string } is excellent TypeScript. The compile-time test with @ts-expect-error on undeclared key access (src/sdk/define-workflow.test.ts) proves the constraint works.

Thorough validation at compile timevalidateWorkflowInput() catches empty/malformed names, enum misconfiguration, and defaults not in enum values. Object.freeze on compile output prevents post-hoc mutation. The input-name regex ^[a-zA-Z][a-zA-Z0-9_-]*$ also prevents CLI flag injection.

Builtin workflows migrated cleanlyralph/claude/index.ts and deep-research-codebase/claude/index.ts both declare prompt as an explicit input and use extractAssistantText(result, 0) for typed extraction from SessionMessage[].

Concerns

1. Interactive picker regression for free-form workflows (src/sdk/components/workflow-picker-panel.tsx)

The deletion of src/sdk/workflow-inputs.ts removed DEFAULT_PROMPT_FIELDS / normalizePickerInputs. With currentFields = focusedWf?.inputs ?? [], a workflow that declares no inputs now renders zero form fields in the picker — the user cannot supply any prompt. isStructured = workflow.inputs.length > 0 means free-form workflows are marked "valid" with no input at all. This is a silent breaking change for any user workflow that relied on the old synthetic prompt field. Options:

  • Auto-inject a prompt input if inputs is empty (preserves old UX).
  • Detect the zero-field case in the picker and show a message + free-form textarea.
  • Document that inputs: [{ name: \"prompt\", type: \"text\", required: true }] is now mandatory for picker-driven workflows.

2. Claude s.session.query() return type is a silent breaking change (src/sdk/providers/claude.ts)

query() now returns SessionMessage[] instead of { output: string }. Existing user workflows doing result.output will compile fine (accessing .output on an array returns undefined) but fail at runtime with confusing errors. Two things would help:

  • Add a validation rule to validateClaudeWorkflow that flags result.output usage on query results.
  • Consider returning a { messages: SessionMessage[], text: string } shape so the common case is ergonomic without extractAssistantText.

3. ClaudeSessionWrapper.query silently drops SDK options in interactive mode (src/sdk/providers/claude.ts)

Interactive query(opts?: Partial<ClaudeQueryDefaults & SDKOptions>) accepts SDK options (e.g., agent, permissionMode, outputFormat) but only the headless path forwards them to sdkQuery. Callers like ralph/claude/index.ts pass { agent: \"codebase-locator\", permissionMode: \"bypassPermissions\" } — these are honored in headless stages but not in interactive ones. At minimum, narrow the type so interactive mode only accepts ClaudeQueryDefaults, or log a warning when SDK-only options are passed interactively.

4. Migration path is abrupt

Both #1 and #2 are breaking without deprecation warnings. Consider a minor release with:

  • A runtime warning when a workflow declares no inputs and is run via the picker.
  • A validator rule for result.output as mentioned in updates to readme and instructions #2.
  • A MIGRATION.md entry under .agents/skills/workflow-creator/ noting these two changes.

Tests

Coverage of the new builder is solid (define-workflow.test.ts):

  • .for() chaining and narrowing ✓
  • Compile-time key restriction via @ts-expect-error
  • Validation: empty inputs, invalid names, enum defaults, duplicates ✓

Gaps worth filling:

  • No test that the picker handles a workflow with inputs: [].
  • No test that the Claude validator catches old result.output patterns.
  • No test for extractAssistantText extraction behavior on edge cases (tool-use-only messages, empty transcripts).

Performance & Security

No concerns. Validation is bounded and runs only at workflow compile/discovery time. Object.freeze is the right call to prevent runtime tampering. The fs/promises.watch()-based waitForSessionFile with polling fallback is a nice improvement over the previous timeout-based approach.

Style / CLAUDE.md alignment

The as unknown as WorkflowBuilder<B, N> cast in .for<B>() is the only unavoidable use of unknown here — a clean exception to the "avoid unknown" guidance. Everything else aligns with the project's Bun/TypeScript conventions.


Overall this is a strong refactor. The ergonomic wins from literal type inference are real, and the test coverage of the new builder is good. My main ask is to address the two breaking changes (#1 picker, #2 Claude return type) before merging, since both fail silently in ways that will be hard for users to debug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant