Getting started
From zero to a running toolup-forge app — package install, first module, full-stack handshake. ~15 minutes.
This walks from an empty solution to a running toolup-forge app: server, Fable client, one Elmish module, type-safe wire. The intended state at the end is "I understand what each file in this project does, and I know which doc to read for any one of them."
Prerequisites
- .NET 10 SDK. The whole stack targets
net10.0/ F# 10. Older SDKs work for some sub-packages but the canonical version is in forge'sglobal.json. - Node 22+ (for the Fable client's Vite dev server and any npm-based plugins like AG Grid).
- A GitHub PAT with
read:packagesscope. TheToolUp.*packages are public on the ToolUp-Forge GitHub Packages feed, but GitHub Packages NuGet requires authentication for restore (a long-standing quirk specific to NuGet on GH Packages — npm and Maven permit anonymous public restore; NuGet does not). SetGITHUB_PACKAGES_USER+GITHUB_PACKAGES_TOKENin your shell profile.
1. Get the easiest possible starting point
Two routes — pick one:
A. Clone the canonical sample. samples/HelloWorld/ in the forge repo is a runnable end-to-end app (server + Fable client + one module + Fable.Remoting wire). Clone forge, build, run.
git clone https://github.com/ToolUp-Forge/toolup-forge.git
cd toolup-forge\samples\HelloWorld
dotnet run
B. Start from empty. Steps 2-6 below scaffold the same shape file by file.
2. Solution skeleton
my-app/
├── global.json # pin .NET 10
├── nuget.config # GitHub Packages feed + credentials
├── Directory.Packages.props # ToolUpSdkVersion coordinated version
├── my-app.sln
├── src/
│ ├── Shared/ # cross-tier types
│ ├── Server/ # Giraffe handlers + composition root
│ └── Client/ # Fable + Elmish + Feliz
└── modules/
└── Hello/ # single domain module (4 files)
The shape mirrors samples/HelloWorld/ — copy that as the reference.
3. Coordinated SDK version
Directory.Packages.props:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<ToolUpSdkVersion>0.4.0</ToolUpSdkVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="ToolUp.Sdk" Version="$(ToolUpSdkVersion)" />
<PackageVersion Include="ToolUp.Platform.Core" Version="$(ToolUpSdkVersion)" />
<PackageVersion Include="ToolUp.Platform.Server" Version="$(ToolUpSdkVersion)" />
<PackageVersion Include="ToolUp.Platform.Client" Version="$(ToolUpSdkVersion)" />
</ItemGroup>
</Project>
ToolUp.Sdk is a meta-manifest — adding it propagates <PackageVersion> entries for every ToolUp.* package at the same version. Bumping the whole SDK is a one-line edit. The meta-package is described under Architecture.
4. Minimum server composition root
In src/Server/Server.fs:
module MyApp.Server
open ToolUp.Platform
open ToolUp.Platform.Server
[<EntryPoint>]
let main _ =
let config = {
ServerConfig.defaults with
Port = 5000
Mode = PlatformMode.Individual
}
ServerApp.empty
|> ServerApp.withConfig config
|> ServerApp.addModules [ Hello.Server.register () ]
|> ServerApp.run
What this does:
- Wires every infrastructure interface to its default in-process implementation (file-backed
IEntityStore, in-processIJobScheduler, in-processINotificationChannel, etc.). - Adds the
Hellomodule'sServerModuleto the registry. - Runs Giraffe over ASP.NET Core on port 5000.
The full composition surface is in Composition roots. Why each field matters in ServerConfig is in Surfaces.
5. Minimum module (the 4-file pattern)
modules/Hello/ ships four files. Full convention is in Modules; minimum shape:
// SharedTypes.fs — record types crossing the wire
module Hello.SharedTypes
type HelloApi = { DoThing: string -> Async<string> }
// Server.fs — handler + registration
module Hello.Server
open ToolUp.Platform
let private api : SharedTypes.HelloApi = {
DoThing = fun input -> async { return sprintf "did: %s" input }
}
let register () : ServerModule =
ServerModule.create "Hello"
|> ServerModule.withGuardedApi api
// ClientModel.fs — Elmish Model/Msg/init/update
module Hello.ClientModel
open Elmish
type Model = { Text: string }
type Msg = NoOp
let init () : Model * Cmd<Msg> = { Text = "" }, Cmd.none
let update _ m = m, Cmd.none
// ClientView.fs — Feliz view + register
module Hello.ClientView
open Feliz
open ToolUp.Platform
open Hello.ClientModel
let view (m: Model) _ =
Html.div [] , Html.div [ Html.text m.Text ]
let register () : ErasedModule =
ClientModule.create {
Init = init
Update = update
Name = "Hello"
Icon = "/svg/chart.svg"
}
|> ClientModule.withView view
|> ClientModule.register
The two register () calls — one on the server side, one on the client side — are what wire the module into the shell. Every module obeys the same shape; the shell handles routing, scope resolution, persistence, auth, AI tool registration.
6. Minimum client composition root
In src/Client/Client.fs:
module MyApp.Client
open ToolUp.Platform
[<EntryPoint>]
let main _ =
Client.empty
|> Client.addModules [ Hello.ClientView.register () ]
|> Client.run
0
Plus a tiny index.html (Vite entry point) and Fable build wiring. The samples/HelloWorld/HelloWorld.Client/ directory shows the full set.
7. Run
# Terminal 1 - server (auto-restarts on .fs changes)
cd src/Server
dotnet watch run
# Terminal 2 - Fable client (recompiles only changed files on save)
cd src/Client
dotnet fable -o output --watch
# Terminal 3 - Vite dev server (HMR)
cd src/Client
npm run dev
Open http://localhost:5000/. The sidebar lists the Hello module; clicking it renders the Feliz view; the DoThing API call returns "did: ..." over Fable.Remoting.
The three-terminal loop is the fast-iteration setup; a single dotnet run -- Run from the FAKE driver also works for one-shot. The difference matters once you're editing — see forge's CLAUDE.md "Fast iteration" for the rationale.
What just happened
| Layer | What it does | Where to read |
|---|---|---|
ServerApp.run |
Stands up Giraffe + ASP.NET Core, wires default interfaces, mounts /api/<module>/... routes for every registered ServerModule, mounts auth + platform-admin + health-check surfaces. |
Composition roots |
Hello.Server.register () |
Returns a ServerModule carrying the HelloApi record. ServerApp.addModules mounts its handlers under /api/Hello/. |
Modules |
Client.run |
Stands up the Elmish runtime, mounts the SDK shell (sidebar, top bar, modals, toast centre, side panel), wires AuthUIProvider, attaches every registered ClientModule. |
Modules |
Fable.Remoting (the wire) |
Auto-generates a typed proxy from the HelloApi record. The client calls api.DoThing "foo" and the server handler runs — no DTOs, no hand-rolled JSON. |
Architecture |
Mode = Individual |
Pins per-user scope isolation. Data the module persists belongs to the signed-in user. | Surfaces |
Next — pick what to add
- An AI assistant — drop in
ToolUp.AI.{Server,Client}+ anIAIProvidercompanion. Walkthrough at AI getting started. - Retrieval-augmented generation —
+ ToolUp.RAG+ToolUp.KnowledgeBase. RAG getting started. - A schema-driven form —
+ ToolUp.Forms.{Server,Client}. Forms getting started. - An auth provider — pick from Auth providers (Microsoft Entra, generic OIDC, Auth0, etc.) and call
ServerApp.withAuth. - Cloud storage — pick a storage provider (AWS S3, Azure Blob, Google Cloud) and call
ServerApp.withStorage.
When it doesn't work
- Restore fails with 401. Set
GITHUB_PACKAGES_USER+GITHUB_PACKAGES_TOKENper the prerequisites above. If you already useghCLI,gh auth refresh -s read:packages && gh auth tokengives you a token that works. - Fable can't find
ClientModule.register. Make sure your Client project includes<PackageReference Include="ToolUp.Platform.Client" />and the Fable source-extraction directive in the SDK's.Client.propsis being honoured. The Architecture doc covers the source-in-nupkg pattern. - Client compiles but the sidebar is empty. The module's
register ()is not being called fromClient.fs, or the module's view is returning(Html.div [], Html.div [])(the right-pane / left-pane tuple). See Modules.
If you're stuck, GitHub Discussions is the place to ask.