Repository F# setup
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScript

Elmish programs

The Axial.ElmishFlows package exposes the Axial.ElmishFlows namespace and connects an Elmish 5 program to Axial workflows. Create one runtime per program, passing an environment and a Post function that delivers callbacks on the host's dispatch thread. When already on that thread, Post should run inline; otherwise it should post to it. Do not call Elmish's dispatch directly from workers. Connect OnDefect to the host's crash reporting path; typed failures are messages, but defects are not.

Install

dotnet add package Axial.ElmishFlows

Axial.ElmishFlows brings Elmish 5 with it. Reference Elmish directly when your program uses Program.mkProgram and dispatch. Open Axial.ElmishFlows for the Axial API, and Axial.PlatformService when your environment needs Clock.live.

A minimal runtime supplies an environment, a Post function, a defect handler, and a history capacity:

Fragment
open Axial
open Axial.ElmishFlows
open Axial.PlatformService

// In an Elmish host, `Post` is usually the program's dispatch, so callbacks run on the host's dispatch thread.
// Here the code is already on that thread, so it runs the callback inline.
let runtime =
    ElmishRuntime.start
        { Post = (fun callback -> callback ())
          OnDefect = (fun defect -> failwith defect.Rendered)
          HistoryCapacity = 32 }
        (ClockEnvironment Clock.live)

When Post is not already on the dispatch thread it must post to it (for example Application.Post in WPF or dispatching back to a UI loop); never call Elmish's dispatch directly from a worker.

First-use lifecycle

ElmishRuntime requires an environment implementing IHasClock. Keep the runtime's clock environment stable, and supply a command's current dependencies with Flow.localEnv. For example, a git client can open another repository without recreating its Elmish runtime:

Fragment
open Axial
open Axial.ElmishFlows
open Axial.PlatformService

type RepositoryEnvironment = { Clock: IClock; Repository: string } with
    interface IHasClock with
        member this.Clock = this.Clock

let runtime =
    ElmishRuntime.start
        { Post = (fun callback -> callback ())
          OnDefect = (fun defect -> failwith defect.Rendered)
          HistoryCapacity = 32 }
        (ClockEnvironment Clock.live)
let currentEnv = { Clock = Clock.live; Repository = "/repos/new" }
let repositoryName : Flow<RepositoryEnvironment, string, string> =
    Flow.envWith (fun env -> env.Repository)

let command =
    Cmd.OfFlow.either "repository" runtime
        (repositoryName |> Flow.localEnv (fun _ -> currentEnv))
        id
        (fun _ -> "unavailable")

Flow.localEnv selects the environment when the command runs; capture the selected environment for each command when constructing it so later repository switches do not change already-issued requests.

Provisioning with startLayer

When a runtime needs resources that must live for the whole program — a Cache.make* cache, queues, hubs, or a Layer.fromFlow resource — build them with a layer and start the runtime with ElmishRuntime.startLayer. The layer is provisioned inside the runtime's root scope, so Stop/StopAndWait releases what the layer acquired. A typed provisioning failure returns Error and leaves nothing running.

Fragment
open Axial
open Axial.Layers
open Axial.ElmishFlows
open Axial.PlatformService

type CacheEnvironment = { Clock: IClock; Cache: Cache<int, Never, string> } with
    interface IHasClock with
        member this.Clock = this.Clock

let layer : Layer<ClockEnvironment, string, CacheEnvironment> =
    Layer.fromFlow (flow {
        let! cache =
            Cache.make (fun key -> Flow.succeed $"value-{key}" : Flow<ClockEnvironment, Never, string>)
        let! clock = Flow.envWith (fun env -> env.Clock)
        return { Clock = clock; Cache = cache }
    })

let runtime =
    match
        ElmishRuntime.startLayer
            { Post = (fun callback -> callback ())
              OnDefect = (fun defect -> failwith defect.Rendered)
              HistoryCapacity = 32 }
            layer
            (ClockEnvironment Clock.live)
        |> Async.RunSynchronously
    with
    | Ok runtime -> runtime
    | Error error -> failwith error

Long-lived services and fire-and-forget

A service that lives as long as the program — a search pipeline, a poller, a cache warmer — belongs in a stream subscription or in a service the runtime provisions, not in a never-completing command. Cmd.OfFlow.perform needs a success message, so running Flow.never through it forces an unreachable onSuccess; use Sub.OfFlowStream.subscribe instead, and let Elmish dispose the subscription (interrupting the stream and releasing its resources) when the program unmounts:

Fragment
open Axial
open Axial.ElmishFlows
open Axial.PlatformService

// A search pipeline backed by an inbox: post queries, subscribe to results for the program's lifetime.
let searchPipeline (runtime: ElmishRuntime<ClockEnvironment>) =
    let inbox = Inbox.create 8 runtime
    let queries : FlowStream<ClockEnvironment, string, string> =
        Inbox.stream inbox |> FlowStream.map (fun query -> $"result:{query}")
    inbox, Sub.OfFlowStream.subscribe [ "search" ] runtime queries (fun error -> $"search-error:{error}")

When the service also needs resources provisioned once for the whole program, ElmishRuntime.startLayer above is the alternative: it builds the cache, queue, or hub inside the runtime's root scope and releases it on Stop/StopAndWait.

Cmd.OfFlow.start is the right tool for fire-and-forget work, but it accepts only Flow<'env, Never, unit>. A flow with a typed failure channel must handle that failure first — log it, then recover to unit:

Show page setup
open System
open Axial
open Axial.PlatformService
let shouldEqual expected actual =
    if actual <> expected then failwithf "Expected %A but got %A" expected actual
Verified
type SearchEnvironment =
    { Clock: IClock
      Log: ILog }
    interface IHasClock with member this.Clock = this.Clock
    interface IHasLog with member this.Log = this.Log

let indexDocument (query: string) : Flow<SearchEnvironment, string, unit> =
    flow {
        if String.IsNullOrWhiteSpace query then
            return! Flow.fail "empty query"
    }

// Handle the typed failure before Cmd.OfFlow.start, which accepts only Flow<'env, Never, unit>.
let indexSafely (query: string) : Flow<SearchEnvironment, Never, unit> =
    indexDocument query
    |> Flow.recover (fun error ->
        flow {
            do! Log.error $"indexing skipped: {error}"
        })
Verified
let log = Log.capturing ()
let env = { Clock = ManualClock(DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); Log = log }

indexSafely "" |> Flow.run env |> shouldEqual (Exit.Success ())

log.Entries |> Seq.toList |> List.map _.Message |> shouldEqual [ "indexing skipped: empty query" ]
Fragment
open Axial
open Axial.ElmishFlows

// indexSafely already handled its typed failure, so its type is Flow<'env, Never, unit>.
let backgroundIndex runtime query =
    Cmd.OfFlow.start "index" runtime (indexSafely query)

Command parameter order

Axial's commands keep Elmish's "work first" idea, but the runtime owns the command's root scope, so it precedes the flow. Pass the flow positionally, not piped:

Fragment
let command =
    Cmd.OfFlow.either "increment" runtime
        (Flow.succeed 2)
        id
        (fun (_: string) -> 0)

The order is name runtime workflow onSuccess onError for either; perform drops onError, attempt drops onSuccess, and Cmd.OfFlow.start name runtime workflow is fire-and-forget: it runs a Flow<'env, Never, unit> as a named fiber in the root scope, dispatches nothing, and reports defects to OnDefect.

Operation reference

The exhaustive operation inventory lives in the API reference:

  • Cmd: Cmd.OfFlow.either/perform/attempt/start and the latest-wins Cmd.OfFlow.Latest variants.
  • Sub: Sub.OfFlowStream.subscribe for running a stream under a subscription id.
  • Inbox: Inbox.create/offer/post/dropped/stream for synchronous UI input and search-as-you-type.
  • ElmishRuntime: start, startLayer, test, and the Stop/StopAndWait shutdown methods.
  • TestRuntime: run, settle, drain, dispatched, and defects for tests with a manual clock.