Repository: akka/akka-sdk
Pull request #363: feat: blocking/virtual thread APIs for workflows
Author: login: johanandren
Labels: name: documentation
description: documentation related
name: java-sdk
Created: 2025-04-15T05:50:40Z | Merged: 2025-04-16T08:13:06Z

<!--
  Are there any relevant issues / PRs / mailing lists discussions?
  Please reference them here - but don't use `fixes` notation.
-->
References https://github.com/lightbend/kalix-runtime/issues/3718



Diff:
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/FraudDetectionResult.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/FraudDetectionResult.java
index 8b54a713c4..9a2431576a 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/FraudDetectionResult.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/FraudDetectionResult.java
@@ -7,9 +7,9 @@
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
 
-public interface FraudDetectionResult {
+public sealed interface FraudDetectionResult {
 
-  class TransferVerified implements FraudDetectionResult{
+  final class TransferVerified implements FraudDetectionResult{
     public final Transfer transfer;
 
     @JsonCreator
@@ -18,7 +18,7 @@ public TransferVerified(@JsonProperty("transfer") Transfer transfer) {
     }
   }
 
-  class TransferRejected implements  FraudDetectionResult {
+  final class TransferRejected implements FraudDetectionResult {
     public final Transfer transfer;
 
     @JsonCreator
@@ -27,7 +27,7 @@ public TransferRejected(@JsonProperty("transfer") Transfer transfer) {
     }
   }
 
-  class TransferRequiresManualAcceptation implements  FraudDetectionResult {
+  final class TransferRequiresManualAcceptation implements FraudDetectionResult {
     public final Transfer transfer;
 
     @JsonCreator
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflow.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflow.java
index a6cb256dc5..582b98d919 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflow.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflow.java
@@ -28,7 +28,7 @@ public TransferWorkflow(ComponentClient componentClient) {
   public WorkflowDef<TransferState> definition() {
     var withdraw =
         step(withdrawStepName)
-            .asyncCall(Withdraw.class, cmd -> componentClient.forKeyValueEntity(cmd.from).method(WalletEntity::withdraw).invokeAsync(cmd.amount))
+            .call(Withdraw.class, cmd -> componentClient.forKeyValueEntity(cmd.from).method(WalletEntity::withdraw).invoke(cmd.amount))
             .andThen(String.class, __ -> {
               var state = currentState().withLastStep("withdrawn").asAccepted();
 
@@ -41,7 +41,7 @@ public WorkflowDef<TransferState> definition() {
 
     var deposit =
         step(depositStepName)
-            .asyncCall(Deposit.class, cmd -> componentClient.forKeyValueEntity(cmd.to).method(WalletEntity::deposit).invokeAsync(cmd.amount)
+            .call(Deposit.class, cmd -> componentClient.forKeyValueEntity(cmd.to).method(WalletEntity::deposit).invoke(cmd.amount)
             ).andThen(String.class, __ -> {
               var state = currentState().withLastStep("deposited").asFinished();
               return effects().updateState(state).end();
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithFraudDetection.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithFraudDetection.java
index 10fe6e58a2..40fd82991f 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithFraudDetection.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithFraudDetection.java
@@ -12,10 +12,6 @@
 import akka.javasdk.client.ComponentClient;
 import akka.javasdk.workflow.Workflow;
 
-import java.util.concurrent.CompletionStage;
-
-import static java.util.concurrent.CompletableFuture.completedFuture;
-
 @ComponentId("transfer-workflow-with-fraud-detection")
 public class TransferWorkflowWithFraudDetection extends Workflow<TransferState> {
 
@@ -33,18 +29,18 @@ public TransferWorkflowWithFraudDetection(ComponentClient componentClient) {
   public WorkflowDef<TransferState> definition() {
     var fraudDetection =
         step(fraudDetectionStepName)
-            .asyncCall(Transfer.class, this::checkFrauds)
+            .call(Transfer.class, this::checkFrauds)
             .andThen(FraudDetectionResult.class, this::processFraudDetectionResult);
 
     var withdraw =
         step(withdrawStepName)
-            .asyncCall(Withdraw.class, cmd ->
-                componentClient.forKeyValueEntity(cmd.from).method(WalletEntity::withdraw).invokeAsync(cmd.amount))
+            .call(Withdraw.class, cmd ->
+                componentClient.forKeyValueEntity(cmd.from).method(WalletEntity::withdraw).invoke(cmd.amount))
             .andThen(String.class, this::moveToDeposit);
 
     var deposit =
         step(depositStepName)
-            .asyncCall(Deposit.class, cmd -> componentClient.forKeyValueEntity(cmd.to).method(WalletEntity::deposit).invokeAsync(cmd.amount))
+            .call(Deposit.class, cmd -> componentClient.forKeyValueEntity(cmd.to).method(WalletEntity::deposit).invoke(cmd.amount))
             .andThen(String.class, this::finishWithSuccess);
 
     return workflow()
@@ -105,37 +101,37 @@ private Effect.TransitionalEffect<Void> moveToDeposit(String response) {
         .transitionTo(depositStepName, depositInput);
   }
 
-  private CompletionStage<FraudDetectionResult> checkFrauds(Transfer transfer) {
+  private FraudDetectionResult checkFrauds(Transfer transfer) {
     if (transfer.amount() >= 1000 && transfer.amount() < 1000000) {
-      return completedFuture(new TransferRequiresManualAcceptation(transfer));
+      return new TransferRequiresManualAcceptation(transfer);
     } else if (transfer.amount() >= 1000000) {
-      return completedFuture(new TransferRejected(transfer));
+      return new TransferRejected(transfer);
     } else {
-      return completedFuture(new TransferVerified(transfer));
+      return new TransferVerified(transfer);
     }
   }
 
   private Effect.TransitionalEffect<Void> processFraudDetectionResult(FraudDetectionResult result) {
     var state = currentState().withLastStep(fraudDetectionStepName);
 
-    if (result instanceof TransferVerified) {
-      var transferVerified = (TransferVerified) result;
-      var withdrawInput = new Withdraw(transferVerified.transfer.from(), transferVerified.transfer.amount());
-
-      return effects()
-          .updateState(state)
-          .transitionTo(withdrawStepName, withdrawInput);
+    switch (result) {
+      case TransferVerified transferVerified -> {
+        var withdrawInput = new Withdraw(transferVerified.transfer.from(), transferVerified.transfer.amount());
 
-    } else if (result instanceof TransferRequiresManualAcceptation) {
-      return effects()
-          .updateState(state)
-          .pause();
-    } else if (result instanceof TransferRejected) {
-      return effects()
-          .updateState(state.asFinished())
-          .end();
-    } else {
-      throw new IllegalStateException("not supported response" + result);
+        return effects()
+            .updateState(state)
+            .transitionTo(withdrawStepName, withdrawInput);
+      }
+      case TransferRequiresManualAcceptation transferRequiresManualAcceptation -> {
+        return effects()
+            .updateState(state)
+            .pause();
+      }
+      case TransferRejected transferRejected -> {
+        return effects()
+            .updateState(state.asFinished())
+            .end();
+      }
     }
   }
 }
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithoutInputs.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithoutInputs.java
index 183bc171b1..b1db12e89f 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithoutInputs.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/TransferWorkflowWithoutInputs.java
@@ -27,9 +27,9 @@ public TransferWorkflowWithoutInputs(ComponentClient componentClient) {
   public WorkflowDef<TransferState> definition() {
     var withdraw =
       step(withdrawStepName)
-        .asyncCall(() -> {
+        .call(() -> {
           var transfer = currentState().transfer();
-          return componentClient.forKeyValueEntity(transfer.from()).method(WalletEntity::withdraw).invokeAsync(transfer.amount());
+          return componentClient.forKeyValueEntity(transfer.from()).method(WalletEntity::withdraw).invoke(transfer.amount());
         })
         .andThen(String.class, response -> {
           var state = currentState().withLastStep("withdrawn").asAccepted();
@@ -54,9 +54,9 @@ public WorkflowDef<TransferState> definition() {
 
     var deposit =
       step(depositStepName)
-        .asyncCall(() -> {
+        .call(() -> {
           var transfer = currentState().transfer();
-          return componentClient.forKeyValueEntity(transfer.to()).method(WalletEntity::deposit).invokeAsync(transfer.amount());
+          return componentClient.forKeyValueEntity(transfer.to()).method(WalletEntity::deposit).invoke(transfer.amount());
         })
         .andThen(String.class, __ -> {
           var state = currentState().withLastStep("deposited").asFinished();
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithDefaultRecoverStrategy.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithDefaultRecoverStrategy.java
index c6c7a88d37..1266004533 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithDefaultRecoverStrategy.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithDefaultRecoverStrategy.java
@@ -9,8 +9,6 @@
 import akka.javasdk.client.ComponentClient;
 import akka.javasdk.workflow.Workflow;
 
-import java.util.concurrent.CompletableFuture;
-
 import static java.time.Duration.ofSeconds;
 
 @ComponentId("workflow-with-default-recover-strategy")
@@ -29,12 +27,12 @@ public WorkflowWithDefaultRecoverStrategy(ComponentClient componentClient) {
   public WorkflowDef<FailingCounterState> definition() {
     var counterInc =
         step(counterStepName)
-            .asyncCall(() -> {
+            .call(() -> {
               var nextValue = currentState().value() + 1;
               return componentClient
                   .forEventSourcedEntity(currentState().counterId())
                   .method(FailingCounterEntity::increase)
-                  .invokeAsync(nextValue);
+                  .invoke(nextValue);
             })
             .andThen(Integer.class, __ -> effects()
                 .updateState(currentState().asFinished())
@@ -42,7 +40,7 @@ public WorkflowDef<FailingCounterState> definition() {
 
     var counterIncFailover =
         step(counterFailoverStepName)
-            .asyncCall(() -> CompletableFuture.completedStage("nothing"))
+            .call(() -> "nothing")
             .andThen(String.class, __ ->
                 effects()
                     .updateState(currentState().inc())
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithRecoverStrategy.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithRecoverStrategy.java
index 6a48486fc8..de0d9c67a0 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithRecoverStrategy.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithRecoverStrategy.java
@@ -9,8 +9,6 @@
 import akka.javasdk.client.ComponentClient;
 import akka.javasdk.workflow.Workflow;
 
-import java.util.concurrent.CompletableFuture;
-
 import static java.time.Duration.ofSeconds;
 
 @ComponentId("workflow-with-recover-strategy")
@@ -30,12 +28,12 @@ public WorkflowWithRecoverStrategy(ComponentClient componentClient) {
   public WorkflowDef<FailingCounterState> definition() {
     var counterInc =
         step(counterStepName)
-            .asyncCall(() -> {
+            .call(() -> {
               var nextValue = currentState().value() + 1;
               return componentClient
                   .forEventSourcedEntity(currentState().counterId())
                   .method(FailingCounterEntity::increase)
-                  .invokeAsync(nextValue);
+                  .invoke(nextValue);
             })
             .andThen(Integer.class, __ -> effects()
                 .updateState(currentState().asFinished())
@@ -43,7 +41,7 @@ public WorkflowDef<FailingCounterState> definition() {
 
     var counterIncFailover =
         step(counterFailoverStepName)
-            .asyncCall(() -> CompletableFuture.completedStage("nothing"))
+            .call(() -> "nothing")
             .andThen(String.class, __ ->
                 effects()
                     .updateState(currentState().inc())
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithStepTimeout.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithStepTimeout.java
index 877b015b3f..fe47f5b559 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithStepTimeout.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithStepTimeout.java
@@ -39,7 +39,7 @@ public WorkflowDef<FailingCounterState> definition() {
 
     var counterIncFailover =
       step(counterFailoverStepName)
-        .asyncCall(() -> CompletableFuture.completedStage("nothing"))
+        .call(() -> "nothing")
         .andThen(String.class, __ -> {
           var updatedState = currentState().inc();
           if (updatedState.value() == 2) {
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimeout.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimeout.java
index 0bbfdf4ef1..a46fc9b419 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimeout.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimeout.java
@@ -42,7 +42,7 @@ public WorkflowDef<FailingCounterState> definition() {
 
     var counterIncFailover =
       step(counterFailoverStepName)
-        .asyncCall(Integer.class, value -> componentClient.forEventSourcedEntity(currentState().counterId()).method(FailingCounterEntity::increase).invokeAsync(value))
+        .call(Integer.class, value -> componentClient.forEventSourcedEntity(currentState().counterId()).method(FailingCounterEntity::increase).invoke(value))
         .andThen(Integer.class, __ ->
           effects()
             .updateState(currentState().asFinished())
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithoutInitialState.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithoutInitialState.java
index d4a1c32bf3..cfaeb90247 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithoutInitialState.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithoutInitialState.java
@@ -7,8 +7,6 @@
 import akka.javasdk.annotations.ComponentId;
 import akka.javasdk.workflow.Workflow;
 
-import java.util.concurrent.CompletableFuture;
-
 @ComponentId("workflow-without-initial-state")
 public class WorkflowWithoutInitialState extends Workflow<String> {
 
@@ -17,7 +15,7 @@ public class WorkflowWithoutInitialState extends Workflow<String> {
   public WorkflowDef<String> definition() {
     var test =
         step("test")
-            .asyncCall(() -> CompletableFuture.completedFuture("ok"))
+            .call(() -> "ok")
             .andThen(String.class, result -> effects().updateState("success").end());
 
     return workflow()
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/hierarchy/TextWorkflow.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/hierarchy/TextWorkflow.java
index 241e3157e2..b25576f4b3 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/hierarchy/TextWorkflow.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/hierarchy/TextWorkflow.java
@@ -14,9 +14,7 @@ public class TextWorkflow extends AbstractTextKvWorkflow {
   @Override
   public WorkflowDef<State> definition() {
     return workflow().addStep(step("dummy-step")
-        .asyncCall(() ->
-            // never completes
-            CompletableFuture.completedFuture("step completed"))
+        .call(() -> "step completed")
         .andThen(String.class, result -> effects().end()));
 
   }
diff --git a/akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java b/akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
index 15f882cfe9..0038677740 100644
--- a/akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
+++ b/akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
@@ -4,8 +4,6 @@
 
 package akka.javasdk.workflow;
 
-import akka.javasdk.DeferredCall;
-
 import java.util.concurrent.CompletionStage;
 import java.util.function.Function;
 import java.util.function.Supplier;
@@ -14,6 +12,9 @@ public class StepBuilder {
 
   final private String name;
 
+  /**
+   * Not for direct user construction, instances are created through the workflow DSL
+   */
   public StepBuilder(String name) {
     this.name = name;
   }
@@ -22,6 +23,42 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> CallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
+    return new CallStepBuilder<>(name, callInputClass, callFactory);
+  }
+
+  /**
+   * Build a step action with a call.
+   * <p>
+   * The {@link Supplier} function passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callSupplier Factory method for creating async call.
+   * @param <Output>     Output of async call.
+   * @return Step builder.
+   */
+  public <Output> CallStepBuilder<Void, Output> call(Supplier<Output> callSupplier) {
+    return new CallStepBuilder<>(name, Void.class, (Void v) -> callSupplier.get());
+  }
+
   /**
    * Build a step action with an async call.
    * <p>
@@ -59,6 +96,38 @@ public <Output> AsyncCallStepBuilder<Void, Output> asyncCall(Supplier<Completion
     return new AsyncCallStepBuilder<>(name, Void.class, (Void v) -> callSupplier.get());
   }
 
+  public static class CallStepBuilder<CallInput, CallOutput> {
+    final private String name;
+
+    final private Class<CallInput> callInputClass;
+    final private Function<CallInput, CallOutput> callFunc;
+
+    CallStepBuilder(String name, Class<CallInput> callInputClass, Function<CallInput, CallOutput> callFunc) {
+      this.name = name;
+      this.callInputClass = callInputClass;
+      this.callFunc = callFunc;
+    }
+
+    /**
+     * Transition to the next step based on the result of the step call.
+     * <p>
+     * The {@link Function} passed to this method should receive the return type of the step call and return
+     * an {@link Workflow.Effect.TransitionalEffect} describing the next step to transition to.
+     * <p>
+     * When defining the Effect, you can update the workflow state and indicate the next step to transition to.
+     * This can be another step, or a pause or end of the workflow.
+     * <p>
+     * When transition to another step, you can also pass an input parameter to the next step.
+     *
+     * @param transitionInputClass Input class for transition.
+     * @param transitionFunc       Function that transform the action result to a {@link Workflow.Effect.TransitionalEffect}
+     * @return AsyncCallStep
+     */
+    public Workflow.CallStep<CallInput, CallOutput, ?> andThen(Class<CallOutput> transitionInputClass, Function<CallOutput, Workflow.Effect.TransitionalEffect<Void>> transitionFunc) {
+      return new Workflow.CallStep<>(name, callInputClass, callFunc, transitionInputClass, transitionFunc);
+    }
+  }
+
 
   public static class AsyncCallStepBuilder<CallInput, CallOutput> {
 
@@ -67,6 +136,9 @@ public static class AsyncCallStepBuilder<CallInput, CallOutput> {
     final private Class<CallInput> callInputClass;
     final private Function<CallInput, CompletionStage<CallOutput>> callFunc;
 
+    /**
+     * Not for direct user construction, created through {{akka.javasdk.workflow.StepBuilder#asyncCall(java.lang.Class, java.util.function.Function)}}
+     */
     public AsyncCallStepBuilder(String name, Class<CallInput> callInputClass, Function<CallInput, CompletionStage<CallOutput>> callFunc) {
       this.name = name;
       this.callInputClass = callInputClass;
diff --git a/akka-javasdk/src/main/java/akka/javasdk/workflow/Workflow.java b/akka-javasdk/src/main/java/akka/javasdk/workflow/Workflow.java
index 06be3b61ea..b3e1477964 100644
--- a/akka-javasdk/src/main/java/akka/javasdk/workflow/Workflow.java
+++ b/akka-javasdk/src/main/java/akka/javasdk/workflow/Workflow.java
@@ -479,6 +479,49 @@ public interface Step {
 
   }
 
+  public static final class CallStep<CallInput, CallOutput, FailoverInput> implements Step {
+
+    final private String _name;
+    final public Function<CallInput, CallOutput> callFunc;
+    final public Function<CallOutput, Effect.TransitionalEffect<Void>> transitionFunc;
+    final public Class<CallInput> callInputClass;
+    final public Class<CallOutput> transitionInputClass;
+    private Optional<Duration> _timeout = Optional.empty();
+
+    /**
+     * Not for direct user construction, instances are created through the workflow DSL
+     */
+    public CallStep(String name,
+                         Class<CallInput> callInputClass,
+                         Function<CallInput, CallOutput> callFunc,
+                         Class<CallOutput> transitionInputClass,
+                         Function<CallOutput, Effect.TransitionalEffect<Void>> transitionFunc) {
+      _name = name;
+      this.callInputClass = callInputClass;
+      this.callFunc = callFunc;
+      this.transitionInputClass = transitionInputClass;
+      this.transitionFunc = transitionFunc;
+    }
+
+    @Override
+    public String name() {
+      return this._name;
+    }
+
+    @Override
+    public Optional<Duration> timeout() {
+      return this._timeout;
+    }
+
+    /**
+     * Define a step timeout.
+     */
+    public CallStep<CallInput, CallOutput, FailoverInput> timeout(Duration timeout) {
+      this._timeout = Optional.of(timeout);
+      return this;
+    }
+  }
+
   public static class AsyncCallStep<CallInput, CallOutput, FailoverInput> implements Step {
 
     final private String _name;
@@ -488,6 +531,9 @@ public static class AsyncCallStep<CallInput, CallOutput, FailoverInput> implemen
     final public Class<CallOutput> transitionInputClass;
     private Optional<Duration> _timeout = Optional.empty();
 
+    /**
+     * Not for direct user construction, instances are created through the workflow DSL
+     */
     public AsyncCallStep(String name,
                          Class<CallInput> callInputClass,
                          Function<CallInput, CompletionStage<CallOutput>> callFunc,
diff --git a/akka-javasdk/src/main/scala/akka/javasdk/impl/SdkRunner.scala b/akka-javasdk/src/main/scala/akka/javasdk/impl/SdkRunner.scala
index ff5ef0cbce..78b1e00ce0 100644
--- a/akka-javasdk/src/main/scala/akka/javasdk/impl/SdkRunner.scala
+++ b/akka-javasdk/src/main/scala/akka/javasdk/impl/SdkRunner.scala
@@ -434,8 +434,11 @@ private final class Sdk(
           .definition()
           .getSteps
           .asScala
-          .flatMap { case asyncCallStep: Workflow.AsyncCallStep[_, _, _] =>
-            List(asyncCallStep.callInputClass, asyncCallStep.transitionInputClass)
+          .flatMap {
+            case asyncCallStep: Workflow.AsyncCallStep[_, _, _] =>
+              List(asyncCallStep.callInputClass, asyncCallStep.transitionInputClass)
+            case callStep: Workflow.CallStep[_, _, _] =>
+              List(callStep.callInputClass, callStep.transitionInputClass)
           }
           .foreach(serializer.registerTypeHints)
 
diff --git a/akka-javasdk/src/main/scala/akka/javasdk/impl/workflow/ReflectiveWorkflowRouter.scala b/akka-javasdk/src/main/scala/akka/javasdk/impl/workflow/ReflectiveWorkflowRouter.scala
index 648a75fab0..0369ba692a 100644
--- a/akka-javasdk/src/main/scala/akka/javasdk/impl/workflow/ReflectiveWorkflowRouter.scala
+++ b/akka-javasdk/src/main/scala/akka/javasdk/impl/workflow/ReflectiveWorkflowRouter.scala
@@ -6,12 +6,10 @@ package akka.javasdk.impl.workflow
 
 import java.util.concurrent.CompletionStage
 import java.util.function.{ Function => JFunc }
-
 import scala.concurrent.ExecutionContext
 import scala.concurrent.Future
 import scala.jdk.FutureConverters.CompletionStageOps
 import scala.jdk.OptionConverters.RichOptional
-
 import akka.annotation.InternalApi
 import akka.javasdk.impl.MethodInvoker
 import akka.javasdk.impl.CommandSerialization
@@ -25,6 +23,7 @@ import akka.javasdk.timer.TimerScheduler
 import akka.javasdk.workflow.CommandContext
 import akka.javasdk.workflow.Workflow
 import akka.javasdk.workflow.Workflow.AsyncCallStep
+import akka.javasdk.workflow.Workflow.CallStep
 import akka.javasdk.workflow.Workflow.Effect.TransitionalEffect
 import akka.javasdk.workflow.WorkflowContext
 import akka.runtime.sdk.spi.BytesPayload
@@ -131,12 +130,22 @@ class ReflectiveWorkflowRouter[S, W <: Workflow[S]](
     val decodedState = decodeUserState(userState).getOrElse(workflow.emptyState())
     workflow._internalSetup(decodedState, commandContext, timerScheduler)
 
+    def decodeInputForClass(inputClass: Class[_]): Any = input match {
+      case Some(inputValue) => decodeInput(inputValue, inputClass)
+      case None             => null // to meet a signature of supplier expressed as a function
+    }
+
     workflow.definition().findByName(stepName).toScala match {
+      case Some(call: CallStep[_, _, _]) =>
+        val decodedInput = decodeInputForClass(call.callInputClass)
+        val output = call.callFunc
+          .asInstanceOf[JFunc[Any, Any]]
+          .apply(decodedInput)
+
+        Future.successful(serializer.toBytes(output))
+
       case Some(call: AsyncCallStep[_, _, _]) =>
-        val decodedInput = input match {
-          case Some(inputValue) => decodeInput(inputValue, call.callInputClass)
-          case None             => null // to meet a signature of supplier expressed as a function
-        }
+        val decodedInput = decodeInputForClass(call.callInputClass)
 
         val future = call.callFunc
           .asInstanceOf[JFunc[Any, CompletionStage[Any]]]
@@ -157,13 +166,19 @@ class ReflectiveWorkflowRouter[S, W <: Workflow[S]](
     // if runtime doesn't have a state to provide, we fall back to user's own defined empty state
     val decodedState = decodeUserState(userState).getOrElse(workflow.emptyState())
     workflow._internalSetup(decodedState)
+
+    def applyTransitionFunc(transitionFunc: JFunc[_, _], transitionInputClass: Class[_]) =
+      transitionFunc
+        .asInstanceOf[JFunc[Any, TransitionalEffect[Any]]]
+        .apply(decodeInput(result, transitionInputClass))
+
     workflow.definition().findByName(stepName).toScala match {
-      case Some(call: AsyncCallStep[_, _, _]) =>
-        val effect =
-          call.transitionFunc
-            .asInstanceOf[JFunc[Any, TransitionalEffect[Any]]]
-            .apply(decodeInput(result, call.transitionInputClass))
+      case Some(call: CallStep[_, _, _]) =>
+        val effect = applyTransitionFunc(call.transitionFunc, call.transitionInputClass)
+        TransitionalResult(effect)
 
+      case Some(call: AsyncCallStep[_, _, _]) =>
+        val effect = applyTransitionFunc(call.transitionFunc, call.transitionInputClass)
         TransitionalResult(effect)
 
       case Some(any) => throw WorkflowStepNotSupported(any.getClass.getSimpleName)
diff --git a/akka-javasdk/src/test/java/akka/javasdk/workflow/TestWorkflowSerialization.java b/akka-javasdk/src/test/java/akka/javasdk/workflow/TestWorkflowSerialization.java
index 8ddee8dc68..5ede66b602 100644
--- a/akka-javasdk/src/test/java/akka/javasdk/workflow/TestWorkflowSerialization.java
+++ b/akka-javasdk/src/test/java/akka/javasdk/workflow/TestWorkflowSerialization.java
@@ -14,7 +14,7 @@ public class TestWorkflowSerialization extends Workflow<String> {
   @Override
   public WorkflowDef<String> definition() {
     var testStep = step("test")
-        .asyncCall(() -> CompletableFuture.<Result>completedFuture(new Result.Succeed()))
+        .<Result>call(Result.Succeed::new)
         .andThen(Result.class, result -> effects().updateState("success").end());
 
     return workflow().addStep(testStep);
diff --git a/docs/src/modules/java/pages/workflows.adoc b/docs/src/modules/java/pages/workflows.adoc
index d995ce9239..4ab317a745 100644
--- a/docs/src/modules/java/pages/workflows.adoc
+++ b/docs/src/modules/java/pages/workflows.adoc
@@ -100,7 +100,7 @@ NOTE: The `@ComponentId` value `transfer` is common for all instances of this wo
 
 == Workflow definition
 
-One missing piece of our transfer workflow implementation is a workflow `definition` method, which composes all steps connected with transitions. A workflow `Step` has a unique name, an action to perform (e.g. asynchronous call to an Akka component, or asynchronous call to any external service) and a transition to select the next step (or `end` transition to finish the workflow, in case of the last step).
+One missing piece of our transfer workflow implementation is a workflow `definition` method, which composes all steps connected with transitions. A workflow `Step` has a unique name, an action to perform (e.g. a call to an Akka component, or a call to an external service) and a transition to select the next step (or `end` transition to finish the workflow, in case of the last step).
 
 ifdef::todo[TODO: add some diagram or sth]
 
@@ -111,7 +111,7 @@ include::example$transfer-workflow/src/main/java/com/example/transfer/applicatio
 ----
 <1> Each step should have a unique name.
 <2> Using the xref:component-and-service-calls.adoc#_component_client[ComponentClient], which is injected in the constructor.
-<3> We instruct Akka to run a given asynchronous call to withdraw funds from a wallet.
+<3> We instruct Akka to run a given call to withdraw funds from a wallet.
 <4> After successful withdrawal we return an `Effect` that will update the workflow state and move to the next step called "deposit." An input parameter for this step is a `Deposit` record.
 <5> Another workflow step action to deposit funds to a given wallet.
 <6> This time we return an effect that will stop workflow processing, by using the special `end` method.
diff --git a/project/SamplesCompilationProject.scala b/project/SamplesCompilationProject.scala
index dc41d08a49..eb53bb192c 100644
--- a/project/SamplesCompilationProject.scala
+++ b/project/SamplesCompilationProject.scala
@@ -11,8 +11,13 @@ import sbt.Test
 
 object SamplesCompilationProject {
 
+  private val LangChain4JVersion = "1.0.0-beta1"
   private val additionalDeps = Map(
-    "spring-dependency-injection" -> Seq("org.springframework" % "spring-context" % "6.2.3"))
+    "spring-dependency-injection" -> Seq("org.springframework" % "spring-context" % "6.2.3"),
+    "ask-akka-agent" -> Seq(
+      "dev.langchain4j" % "langchain4j-open-ai" % LangChain4JVersion,
+      "dev.langchain4j" % "langchain4j" % LangChain4JVersion,
+      "dev.langchain4j" % "langchain4j-mongodb-atlas" % LangChain4JVersion))
 
   def compilationProject(configureFunc: Project => Project): CompositeProject = {
     val pathToSample = "samples"
diff --git a/samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java b/samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
index e819e78bc2..fed1289f43 100644
--- a/samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
+++ b/samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
@@ -26,10 +26,10 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
 import java.util.stream.Stream;
 
+import static akka.Done.done;
+
 /**
  * This workflow reads the files under src/main/resources/flat-doc/ and create
  * the vector embeddings that are later
@@ -43,12 +43,11 @@ public class RagIndexingWorkflow extends Workflow<RagIndexingWorkflow.State> {
   private final OpenAiEmbeddingModel embeddingModel;
   private final MongoDbEmbeddingStore embeddingStore;
   private final DocumentSplitter splitter;
+
   // metadata key used to store file name
   private final String srcKey = "src";
   private static final String PROCESSING_FILE_STEP = "processing-file";
 
-  private final CompletionStage<Done> futDone = CompletableFuture.completedFuture(Done.getInstance());
-
   public record State(List<Path> toProcess, List<Path> processed) { // <1>
 
     public static State of(List<Path> toProcess) {
@@ -127,7 +126,7 @@ public Effect<Done> start() {
       return effects()
           .updateState(State.of(documents))
           .transitionTo(PROCESSING_FILE_STEP) // <1>
-          .thenReply(Done.getInstance());
+          .thenReply(done());
     }
   }
   // end::start[]
@@ -138,7 +137,7 @@ public Effect<Done> abort() {
     return effects()
         .updateState(emptyState())
         .pause()
-        .thenReply(Done.getInstance());
+        .thenReply(done());
   }
 
   // tag::def[]
@@ -146,11 +145,11 @@ public Effect<Done> abort() {
   public WorkflowDef<State> definition() {
 
     var processing = step(PROCESSING_FILE_STEP) // <1>
-        .asyncCall(() -> {
+        .call(() -> {
           if (currentState().hasFilesToProcess()) {
-            return indexFile(currentState().head());
-          } else
-            return futDone;
+            indexFile(currentState().head().get());
+          }
+          return done();
         })
         .andThen(Done.class, __ -> {
           // we need to check if it hasFilesToProcess, before moving the head
@@ -170,52 +169,35 @@ public WorkflowDef<State> definition() {
   // end::def[]
 
   // tag::index[]
-  private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
-
-    if (pathOpt.isEmpty())
-      return futDone;
-    else {
-
-      var path = pathOpt.get();
-      try (InputStream input = Files.newInputStream(path)) {
-        // read file as input stream
-        Document doc = new TextDocumentParser().parse(input);
-        var docWithMetadata = new DefaultDocument(doc.text(), Metadata.metadata(srcKey, path.getFileName().toString()));
-
-        var segments = splitter.split(docWithMetadata);
-        logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
-
-        return segments
-            .stream()
-            .reduce(
-                futDone,
-                (acc, seg) -> addSegment(seg),
-                (stage1, stage2) -> futDone);
-
-      } catch (BlankDocumentException e) {
-        // some documents are blank, we need to skip them
-        return futDone;
-      } catch (Exception e) {
-        logger.error("Error reading file: {} - {}", path, e.getMessage());
-        return futDone;
-      }
+  private void indexFile(Path path) {
+    try (InputStream input = Files.newInputStream(path)) {
+      // read file as input stream
+      Document doc = new TextDocumentParser().parse(input);
+      var docWithMetadata = new DefaultDocument(doc.text(), Metadata.metadata(srcKey, path.getFileName().toString()));
+
+      var segments = splitter.split(docWithMetadata);
+      logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
+
+      segments.forEach(this::addSegment);
+    } catch (BlankDocumentException e) {
+      // some documents are blank, we need to skip them
+    } catch (Exception e) {
+      logger.error("Error reading file: {} - {}", path, e.getMessage());
     }
   }
   // end::index[]
 
   // tag::add[]
-  private CompletionStage<Done> addSegment(TextSegment seg) {
+  private void addSegment(TextSegment seg) {
     var fileName = seg.metadata().getString(srcKey);
-    return CompletableFuture.supplyAsync(() -> embeddingModel.embed(seg))
-        .thenCompose(res -> CompletableFuture.supplyAsync(() -> {
-          logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
-              fileName,
-              res.tokenUsage().inputTokenCount(),
-              res.tokenUsage().outputTokenCount());
-
-          return embeddingStore.add(res.content(), seg); // <1>
-        }))
-        .thenApply(__ -> Done.getInstance());
+    var res = embeddingModel.embed(seg);
+
+    logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
+        fileName,
+        res.tokenUsage().inputTokenCount(),
+        res.tokenUsage().outputTokenCount());
+
+    embeddingStore.add(res.content(), seg); // <1>
   }
   // end::add[]
   // tag::shell[]
diff --git a/samples/key-value-shopping-cart/README.md b/samples/key-value-shopping-cart/README.md
index 8b113cf3cf..fa3ab07758 100644
--- a/samples/key-value-shopping-cart/README.md
+++ b/samples/key-value-shopping-cart/README.md
@@ -54,7 +54,6 @@ curl localhost:9000/carts/cart1
 
 * Remove a shopping cart via controller:
 
-FIXME API support for header extraction yet
 ```shell
 curl -XDELETE -H "UserRole: Admin" localhost:9000/carts/cart1
 ```
diff --git a/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java b/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java
index 646e974780..e8eb435733 100644
--- a/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java
+++ b/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java
@@ -15,8 +15,6 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.concurrent.CompletableFuture;
-
 import static com.example.transfer.domain.TransferState.TransferStatus.COMPENSATION_COMPLETED;
 import static com.example.transfer.domain.TransferState.TransferStatus.COMPLETED;
 import static com.example.transfer.domain.TransferState.TransferStatus.DEPOSIT_FAILED;
@@ -44,7 +42,7 @@ public TransferWorkflow(ComponentClient componentClient) {
   public WorkflowDef<TransferState> definition() {
     Step withdraw =
       step("withdraw")
-        .asyncCall(Withdraw.class, cmd -> {
+        .call(Withdraw.class, cmd -> {
           logger.info("Running withdraw: {}", cmd);
 
           // cancelling the timer in case it was scheduled
@@ -52,7 +50,7 @@ public WorkflowDef<TransferState> definition() {
 
           return componentClient.forEventSourcedEntity(currentState().transfer().from())
               .method(WalletEntity::withdraw)
-              .invokeAsync(cmd);
+              .invoke(cmd);
         })
         .andThen(WalletResult.class, result -> {
           switch (result) {
@@ -75,13 +73,13 @@ public WorkflowDef<TransferState> definition() {
     // tag::compensation[]
     Step deposit =
       step("deposit")
-        .asyncCall(Deposit.class, cmd -> {
+        .call(Deposit.class, cmd -> {
           // end::compensation[]
           logger.info("Running deposit: {}", cmd);
           // tag::compensation[]
           return componentClient.forEventSourcedEntity(currentState().transfer().to())
             .method(WalletEntity::deposit)
-            .invokeAsync(cmd);
+            .invoke(cmd);
         })
         .andThen(WalletResult.class, result -> { // <1>
           switch (result) {
@@ -103,7 +101,7 @@ public WorkflowDef<TransferState> definition() {
 
     Step compensateWithdraw =
       step("compensate-withdraw") // <4>
-        .asyncCall(() -> {
+        .call(() -> {
           // end::compensation[]
           logger.info("Running withdraw compensation");
           // tag::compensation[]
@@ -114,7 +112,7 @@ public WorkflowDef<TransferState> definition() {
           String commandId = currentState().depositId();
           return componentClient.forEventSourcedEntity(transfer.from())
             .method(WalletEntity::deposit)
-            .invokeAsync(new Deposit(commandId, transfer.amount()));
+            .invoke(new Deposit(commandId, transfer.amount()));
         })
         .andThen(WalletResult.class, result -> {
           switch (result) {
@@ -133,11 +131,11 @@ public WorkflowDef<TransferState> definition() {
     // tag::step-timeout[]
     Step failoverHandler =
       step("failover-handler")
-        .asyncCall(() -> {
+        .call(() -> {
           // end::step-timeout[]
           logger.info("Running workflow failed step");
           // tag::step-timeout[]
-          return CompletableFuture.completedStage("handling failure");
+          return "handling failure";
         })
         .andThen(String.class, __ -> effects()
           .updateState(currentState().withStatus(REQUIRES_MANUAL_INTERVENTION))
@@ -148,7 +146,7 @@ public WorkflowDef<TransferState> definition() {
     // tag::pausing[]
     Step waitForAcceptation =
       step("wait-for-acceptation")
-        .asyncCall(() -> {
+        .call(() -> {
           String transferId = currentState().transferId();
           timers().startSingleTimer(
             "acceptationTimeout-" + transferId,
@@ -156,7 +154,7 @@ public WorkflowDef<TransferState> definition() {
             componentClient.forWorkflow(transferId)
               .method(TransferWorkflow::acceptationTimeout)
               .deferred()); // <1>
-          return CompletableFuture.completedFuture(Done.done()); // FIXME remove once we have sync/blocking workflow calls
+          return Done.done();
         })
         .andThen(Done.class, __ ->
           effects().pause()); // <2>
diff --git a/samples/transfer-workflow/src/main/java/com/example/transfer/application/TransferWorkflow.java b/samples/transfer-workflow/src/main/java/com/example/transfer/application/TransferWorkflow.java
index d13db262d2..5945dfc04c 100644
--- a/samples/transfer-workflow/src/main/java/com/example/transfer/application/TransferWorkflow.java
+++ b/samples/transfer-workflow/src/main/java/com/example/transfer/application/TransferWorkflow.java
@@ -38,10 +38,10 @@ public TransferWorkflow(ComponentClient componentClient) {
   public WorkflowDef<TransferState> definition() {
     Step withdraw =
       step("withdraw") // <1>
-        .asyncCall(Withdraw.class, cmd ->
+        .call(Withdraw.class, cmd ->
           componentClient.forEventSourcedEntity(cmd.from) // <2>
             .method(WalletEntity::withdraw)
-            .invokeAsync(cmd.amount)) // <3>
+            .invoke(cmd.amount)) // <3>
         .andThen(Done.class, __ -> {
           Deposit depositInput = new Deposit(currentState().transfer().to(), currentState().transfer().amount());
           return effects()
@@ -51,10 +51,10 @@ public WorkflowDef<TransferState> definition() {
 
     Step deposit =
       step("deposit") // <5>
-        .asyncCall(Deposit.class, cmd ->
+        .call(Deposit.class, cmd ->
           componentClient.forEventSourcedEntity(cmd.to)
             .method(WalletEntity::deposit)
-            .invokeAsync(cmd.amount))
+            .invoke(cmd.amount))
         .andThen(Done.class, __ -> {
           return effects()
             .updateState(currentState().withStatus(COMPLETED))


Reviews:
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T05:52:51Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T12:12:14Z
user: login: octonato
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T12:51:55Z
user: login: octonato
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:04:39Z
user: login: patriknw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:13:29Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:35:47Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:36:37Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:38:45Z
user: login: octonato
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:10:00Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:30:42Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:31:13Z
user: login: octonato
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T14:35:48Z
user: login: octonato
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:37:01Z
user: login: patriknw
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:08:08Z

Review comments:
diff_hunk: @@ -179,38 +177,25 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
 
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
-
-        return segments
-          .stream()
-          .reduce(
-            futDone,
-            (acc, seg) -> addSegment(seg),
-            (stage1, stage2) -> futDone
-          );
+        segments.forEach(this::addSegment);
 
       } catch (BlankDocumentException e) {
         // some documents are blank, we need to skip them
-        return futDone;
       } catch (Exception e) {
         logger.error("Error reading file: {} - {}", path, e.getMessage());
-        return futDone;
       }
     }
   }
 
-  private CompletionStage<Done> addSegment(TextSegment seg) {
+  private void addSegment(TextSegment seg) {
     var fileName = seg.metadata().getString(srcKey);
-    return
-      CompletableFuture.supplyAsync(() -> embeddingModel.embed(seg))
-        .thenCompose(res ->
-          CompletableFuture.supplyAsync(() -> {
-            logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
-              fileName,
-              res.tokenUsage().inputTokenCount(),
-              res.tokenUsage().outputTokenCount());
-
-            return embeddingStore.add(res.content(), seg);
-          }))
-        .thenApply(__ -> Done.getInstance());
+    var res = embeddingModel.embed(seg);
+    logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
+        fileName,
+        res.tokenUsage().inputTokenCount(),
+        res.tokenUsage().outputTokenCount());
+
+    embeddingStore.add(res.content(), seg);
+    logger.debug("Added segment to embedding store '{}' to embedding", fileName);
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: johanandren
body: I changed this quite a bit, it was likely starving the default thread pool as it was, and did not compose all futures into a final result correctly. 

If we want it to run the addSegment calls in parallel I can revisit it and make a correct version for parallelizing those. I have a feeling the intent was to do them sequentially though, given that it tried to use `reduce`.
created_at: 2025-04-15T05:52:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -179,38 +177,25 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
 
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
-
-        return segments
-          .stream()
-          .reduce(
-            futDone,
-            (acc, seg) -> addSegment(seg),
-            (stage1, stage2) -> futDone
-          );
+        segments.forEach(this::addSegment);
 
       } catch (BlankDocumentException e) {
         // some documents are blank, we need to skip them
-        return futDone;
       } catch (Exception e) {
         logger.error("Error reading file: {} - {}", path, e.getMessage());
-        return futDone;
       }
     }
   }
 
-  private CompletionStage<Done> addSegment(TextSegment seg) {
+  private void addSegment(TextSegment seg) {
     var fileName = seg.metadata().getString(srcKey);
-    return
-      CompletableFuture.supplyAsync(() -> embeddingModel.embed(seg))
-        .thenCompose(res ->
-          CompletableFuture.supplyAsync(() -> {
-            logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
-              fileName,
-              res.tokenUsage().inputTokenCount(),
-              res.tokenUsage().outputTokenCount());
-
-            return embeddingStore.add(res.content(), seg);
-          }))
-        .thenApply(__ -> Done.getInstance());
+    var res = embeddingModel.embed(seg);
+    logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
+        fileName,
+        res.tokenUsage().inputTokenCount(),
+        res.tokenUsage().outputTokenCount());
+
+    embeddingStore.add(res.content(), seg);
+    logger.debug("Added segment to embedding store '{}' to embedding", fileName);
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: johanandren
body: Now that we have the virtual thread dispatcher for injection, I reimplemented this how I think it originally was meant to work: 

Fire off all segments for a file in parallel, then wait for all of them to complete before going to next file. Fail the entire thing if any of the parallel tasks throws an exception.
created_at: 2025-04-15T12:12:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -179,38 +177,25 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
 
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
-
-        return segments
-          .stream()
-          .reduce(
-            futDone,
-            (acc, seg) -> addSegment(seg),
-            (stage1, stage2) -> futDone
-          );
+        segments.forEach(this::addSegment);
 
       } catch (BlankDocumentException e) {
         // some documents are blank, we need to skip them
-        return futDone;
       } catch (Exception e) {
         logger.error("Error reading file: {} - {}", path, e.getMessage());
-        return futDone;
       }
     }
   }
 
-  private CompletionStage<Done> addSegment(TextSegment seg) {
+  private void addSegment(TextSegment seg) {
     var fileName = seg.metadata().getString(srcKey);
-    return
-      CompletableFuture.supplyAsync(() -> embeddingModel.embed(seg))
-        .thenCompose(res ->
-          CompletableFuture.supplyAsync(() -> {
-            logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
-              fileName,
-              res.tokenUsage().inputTokenCount(),
-              res.tokenUsage().outputTokenCount());
-
-            return embeddingStore.add(res.content(), seg);
-          }))
-        .thenApply(__ -> Done.getInstance());
+    var res = embeddingModel.embed(seg);
+    logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
+        fileName,
+        res.tokenUsage().inputTokenCount(),
+        res.tokenUsage().outputTokenCount());
+
+    embeddingStore.add(res.content(), seg);
+    logger.debug("Added segment to embedding store '{}' to embedding", fileName);
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: octonato
body: Yeah, I wrote this code initially. My intention was indeed to chain the calls to `addSegment`, but somehow I end up returning `futDone`. My bad. 

Looking at it once again, I think it should be ok to just ignore a file if for some reason it fails to index. 

created_at: 2025-04-15T12:51:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,37 +183,38 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
 
-        return segments
+        // add segments in parallel
+        List<CompletableFuture<Done>> parallelAddSegments = segments
             .stream()
-            .reduce(
-                futDone,
-                (acc, seg) -> addSegment(seg),
-                (stage1, stage2) -> futDone);
+            .map(segment -> CompletableFuture.supplyAsync(() -> {
+              addSegment(segment);
+              return Done.done();
+            }, virtualThreadExecutor))
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: octonato
body: Is that needed? I think this will only add complexity to the sample. 

It should always be fine to run CompletionStage on the default ForkJoinPool and then wait for the results on a virtual thread.


created_at: 2025-04-15T12:56:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,37 +183,38 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
 
-        return segments
+        // add segments in parallel
+        List<CompletableFuture<Done>> parallelAddSegments = segments
             .stream()
-            .reduce(
-                futDone,
-                (acc, seg) -> addSegment(seg),
-                (stage1, stage2) -> futDone);
+            .map(segment -> CompletableFuture.supplyAsync(() -> {
+              addSegment(segment);
+              return Done.done();
+            }, virtualThreadExecutor))
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: octonato
body: Since we are not aiming for parallelizing the indexing, why do we care about the segments? 
created_at: 2025-04-15T12:58:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +21,54 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> AsyncCallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
path: akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
user: login: octonato
body: Is there still a reason to have `asyncCall`?
We can keep it anyway, but I think that its usage becomes obsolete with Virtual Threads.
created_at: 2025-04-15T13:03:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +21,54 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> AsyncCallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
path: akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
user: login: patriknw
body: We have both in all places, and here it useful for the same reasons that we have it in other places. E.g. running several things in parallel and compose. It's also important to keep for ease of migration (compatibility).
created_at: 2025-04-15T13:13:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,37 +183,38 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
 
-        return segments
+        // add segments in parallel
+        List<CompletableFuture<Done>> parallelAddSegments = segments
             .stream()
-            .reduce(
-                futDone,
-                (acc, seg) -> addSegment(seg),
-                (stage1, stage2) -> futDone);
+            .map(segment -> CompletableFuture.supplyAsync(() -> {
+              addSegment(segment);
+              return Done.done();
+            }, virtualThreadExecutor))
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: johanandren
body: If it is really needed was my initial question, but since it was written like that was attempted I assumed the network calls are slow or something.

The problem here is that the operations in addSegment is doing blocking calls, first to generate the embedding, then to mongodb, so that cannot be run on the default/common FJP safely (like how it was done).
created_at: 2025-04-15T13:35:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +21,54 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> AsyncCallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
path: akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
user: login: johanandren
body: I don't think it is obsolete, just that it isn't very often the first choice.
created_at: 2025-04-15T13:36:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -179,38 +177,25 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
 
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
-
-        return segments
-          .stream()
-          .reduce(
-            futDone,
-            (acc, seg) -> addSegment(seg),
-            (stage1, stage2) -> futDone
-          );
+        segments.forEach(this::addSegment);
 
       } catch (BlankDocumentException e) {
         // some documents are blank, we need to skip them
-        return futDone;
       } catch (Exception e) {
         logger.error("Error reading file: {} - {}", path, e.getMessage());
-        return futDone;
       }
     }
   }
 
-  private CompletionStage<Done> addSegment(TextSegment seg) {
+  private void addSegment(TextSegment seg) {
     var fileName = seg.metadata().getString(srcKey);
-    return
-      CompletableFuture.supplyAsync(() -> embeddingModel.embed(seg))
-        .thenCompose(res ->
-          CompletableFuture.supplyAsync(() -> {
-            logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
-              fileName,
-              res.tokenUsage().inputTokenCount(),
-              res.tokenUsage().outputTokenCount());
-
-            return embeddingStore.add(res.content(), seg);
-          }))
-        .thenApply(__ -> Done.getInstance());
+    var res = embeddingModel.embed(seg);
+    logger.debug("Segment embedded. Source file '{}'. Tokens usage: in {}, out {}",
+        fileName,
+        res.tokenUsage().inputTokenCount(),
+        res.tokenUsage().outputTokenCount());
+
+    embeddingStore.add(res.content(), seg);
+    logger.debug("Added segment to embedding store '{}' to embedding", fileName);
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: johanandren
body: That is just one of the aspects, a try-catch-ignore, the parallelism and managing that o some extent, rather than fork of all segments for all files as fast as possible.

I'm fine with doing it sequentially though, if there isn't a problem to do embed, then store embedding for each segment, one at a time. Simplifies the code a bit.
created_at: 2025-04-15T13:38:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +21,54 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> AsyncCallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
path: akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
user: login: octonato
body: if we don't want to deprecate it, then it's fine to have just one single AsyncStepBuilder that handles both cases. 


created_at: 2025-04-15T14:10:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,37 +183,38 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
 
-        return segments
+        // add segments in parallel
+        List<CompletableFuture<Done>> parallelAddSegments = segments
             .stream()
-            .reduce(
-                futDone,
-                (acc, seg) -> addSegment(seg),
-                (stage1, stage2) -> futDone);
+            .map(segment -> CompletableFuture.supplyAsync(() -> {
+              addSegment(segment);
+              return Done.done();
+            }, virtualThreadExecutor))
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: johanandren
body: Back to sequential in [36bf621](https://github.com/akka/akka-sdk/pull/363/commits/36bf621d99df11a47cd16d755a91d84d89e93c3d)
created_at: 2025-04-15T14:30:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +21,54 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> AsyncCallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
path: akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
user: login: johanandren
body: Already implemented in https://github.com/akka/akka-sdk/pull/363/commits/1785cb207ea5fe2f3b254e222aa7a36963548985 (are you just trying to keep me busy? ;) )
created_at: 2025-04-15T14:31:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -185,37 +183,38 @@ private CompletionStage<Done> indexFile(Optional<Path> pathOpt) {
         var segments = splitter.split(docWithMetadata);
         logger.debug("Created {} segments for document {}", segments.size(), path.getFileName());
 
-        return segments
+        // add segments in parallel
+        List<CompletableFuture<Done>> parallelAddSegments = segments
             .stream()
-            .reduce(
-                futDone,
-                (acc, seg) -> addSegment(seg),
-                (stage1, stage2) -> futDone);
+            .map(segment -> CompletableFuture.supplyAsync(() -> {
+              addSegment(segment);
+              return Done.done();
+            }, virtualThreadExecutor))
path: samples/ask-akka-agent/src/main/java/akka/ask/indexer/application/RagIndexingWorkflow.java
user: login: octonato
body: In that case, and for simplification, I think we should not wrap it on a CompletableFuture and just let it run on the virtual thread implicitly. 


created_at: 2025-04-15T14:35:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +21,54 @@ public StepBuilder step(String stepName) {
     return new StepBuilder(stepName);
   }
 
+  /**
+   * Build a step action with a  call.
+   * <p>
+   * The {@link Function} passed to this method should return a {@link CompletionStage}.
+   * On successful completion, its result is made available to this workflow via the {@code andThen} method.
+   * In the {@code andThen} method, we can use the result to update the workflow state and transition to the next step.
+   * <p>
+   * On failure, the step will be retried according to the default retry strategy or the one defined in the step configuration.
+   *
+   * @param callInputClass Input class for call factory.
+   * @param callFactory    Factory method for creating async call.
+   * @param <Input>        Input for async call factory, provided by transition method.
+   * @param <Output>       Output of async call.
+   * @return Step builder.
+   */
+  public <Input, Output> AsyncCallStepBuilder<Input, Output> call(Class<Input> callInputClass, Function<Input, Output> callFactory) {
path: akka-javasdk/src/main/java/akka/javasdk/workflow/StepBuilder.java
user: login: octonato
body: kind of...

Fine, let's keep it in that case. 
created_at: 2025-04-15T15:37:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: johanandren
created_at: 2025-04-15T06:04:44Z
body: I think this API looks fine, but not entirely sure that the impl re-using the `AsyncCallStepBuilder` is the right way to go vs making a separate one, mostly did so because it was the shortest path.
author_association: CONTRIBUTOR
user: login: octonato
created_at: 2025-04-15T13:12:10Z
body: > I think this API looks fine, but not entirely sure that the impl re-using the `AsyncCallStepBuilder` is the right way to go vs making a separate one, mostly did so because it was the shortest path.

It will be better to have a separated one, IMO. And also deprecate the async one (see my comment above). 


author_association: CONTRIBUTOR
user: login: johanandren
created_at: 2025-04-15T13:39:56Z
body: Ok, I'll write a parallel set of classes then, but note that they will be pretty identical to the existing except for the name and the lambda return type.
author_association: CONTRIBUTOR
user: login: patriknw
created_at: 2025-04-15T14:31:55Z
body: seems to be some back-n-forth in the discussion here? duplicate the step builders or use the async? https://github.com/akka/akka-sdk/pull/363#discussion_r2044671283
author_association: CONTRIBUTOR
user: login: octonato
created_at: 2025-04-15T14:43:56Z
body: > seems to be some back-n-forth in the discussion here? duplicate the step builders or use the async? [#363 (comment)](https://github.com/akka/akka-sdk/pull/363#discussion_r2044671283)

My last argument was to not duplicate because we want to keep the `asyncCall`. 
When I argued for duplicating, I had in mind the possibility of deprecating `asyncCall`. 


author_association: CONTRIBUTOR
user: login: johanandren
created_at: 2025-04-15T17:00:59Z
body: I may be biased since I spent the time separating the builders, but I now think it _looks_ better to not return `AsyncStepBuilder` when building a non async step :) 
author_association: CONTRIBUTOR