When preparing build configuration in sbt, you might want to write custom sbt task. Task in sbt is like a function that can depend on other tasks and project settings.

Custom sbt tasks

To illustrate it, let’s write a simple task that validates environment, runs tests and builds an app

import sbt.*

lazy val validateAndBuild = taskKey[Unit]("Validates env, runs tests and builds the app")

val root = project.in(file(".")).settings(
  validateAndBuild := {
    validateEnvVars
    test
    assembly
  }
)

Tasks vs immutable settings

This can be handy when the project grows in complexity. One problem with this approach is that we don’t control the inputs to the sub-tasks that we run. In sbt, settings are initialized when the project is loaded and are not mutable.

import sbt.*

// Tasks that attempt to modify the settings
lazy val updateVersion = taskKey[Unit]("blah")
lazy val addDependency = taskKey[Unit]("blah")

val root = project.in(file(".")).settings(
  name := "My project",
  version := "0.0.0",
  updateVersion := {
    version := "0.1.0"
    println(s"Current version is ${version.value}")
  },
  addDependency := {
    libraryDependencies += "org.polyvariant" %% "example" % "2.1.37"
  }
)

As shown below, running the tasks doesn’t affect the settings values.

sbt:My project> show version
[info] 0.0.0
sbt:My project> show libraryDependencies
[info] * org.scala-lang:scala-library:2.12.20
sbt:My project> updateVersion
Current version is 0.0.0
[success] Total time: 0 s, completed Oct 5, 2025, 6:52:17 PM
sbt:My project> addDependency
[success] Total time: 0 s, completed Oct 5, 2025, 6:52:21 PM
sbt:My project> show version
[info] 0.0.0
sbt:My project> show libraryDependencies
[info] * org.scala-lang:scala-library:2.12.20

Now let’s say you want to add a task to your build that assembles the app using sbt-assembly but using a custom Main class that enables extra debug symbols. Our task would be called buildDebugApp and would run assembly but with a custom assembly / mainClass setting.

The most intuitive approach would be to temporarily override the main class, execute assembly, and then restore the old mainClass value.

lazy val buildDebugApp = taskKey[Unit]("Assemble a debug version of this app with custom main class")

val root = project.in(file(".")).settings(
  buildDebugApp := {
    val oldMain = (assembly / mainClass).value
    assembly / mainClass := Some("org.example.DebugMain")
    assembly
    assembly / mainClass := oldMain
  }
)

Unfortunately, as we learned above, this doesn’t work.

Project.extract and running tasks with new state

To overcome this issue we’ll have to use Project.extract. Because tasks read the current project state to obtain their dependent values, the only way to override those values is to run the tasks in a new environment. Even though settings are immutable, we can extract the current project state with Project.extract(state.value), modify that state, and run a task in the modified environment like this:

lazy val buildDebugApp = taskKey[Unit]("Assemble a debug version of this app with custom main class")

val root = project.in(file(".")).settings(
  buildDebugApp := {
    // Capture current project state and reference
    val initialState = state.value
    val tpr = thisProjectRef.value

    // Prepare the value overrides
    val customMain = Some("org.example.DebugMain")
    val overrideSettings = Seq(
      tpr / assembly / mainClass := customMain,
    )

    // Prepare a copy of current state with value overrides
    val currentState = Project.extract(initialState)
    val newState = currentState.appendWithSession(overrideSettings, initialState)

    // Run assembly in the modified environment
    Project
      .extract(newState)
      .runTask(tpr / assembly, newState)
  }
)

You can also use runInputTask if you’re executing an InputTask.