Build Your First App: hello_world
This tutorial walks you end-to-end through building a real Kuetix project that
returns "Hello World" two ways: as a CLI binary and as an HTTP endpoint, both
backed by the same workflow file.
By the end you will have:
- A new project at
~/Projects/hello_worldcreated withkue create. - A
hello/hello.HelloGo transition that returns"Hello World". - A
workflows/hello_world.wslworkflow that calls the transition and emits a response. - A CLI binary that prints
Result: Hello World. - An API binary that serves
GET /hello_world→{"success":true,"data":"Hello World"}.
:::tip Companion document
The unabridged engineering log from the original run of this demo — including
every error message, decision, and side-quest — lives at
DEMO.md
in the docs repo. Reach for it when something behaves unexpectedly.
:::
Prerequisites
- Go 1.21+ — Download Go
- git with access to the
kuetix/kue,kuetix/engine,kuetix/packages/core, andkuetix/packages/httprepos (they're currently private; clone them all into~/Projects/kuetix/). - An account on
https://api.kuetix.com(registration is free).
Throughout this guide we assume your sibling checkouts live at:
~/Projects/kuetix/kue
~/Projects/kuetix/engine
~/Projects/kuetix/packages/core # std-core
~/Projects/kuetix/packages/http # std-http
1. Install kue
The kue CLI is the entry point for everything that follows.
cd ~/Projects/kuetix/kue
make cli # builds runtime/bin/kue
make install # copies it to $HOME/go/bin
:::caution Run them together
make install does not depend on make cli. If you run only make install
you may end up copying a stale binary. Always pair them: make cli && make install.
:::
Verify:
kue version
2. Log in to the registry
The registry stores both packages and workflows. Log in once and kue will
cache your JWT under ~/.kue/config.json.
kue login -H https://api.kuetix.com -u <your-email> -p <your-password>
Confirm the token works by listing your workflows:
kue workflow list
# → Result: [] (empty is fine — you haven't uploaded any yet)
3. Create the project
cd ~/Projects
kue create -a cli -n hello_world
cd hello_world
This generates:
hello_world/
├── application.json
├── cmd/cli/main.go
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── Makefile
├── modules/modules.go
├── README.md
└── workflows/ (empty)
4. Browse the workflow registry (optional)
You can list and install workflows other users have published:
kue workflow list # list workflows you can see
kue install <namespace>/<name> # downloads it into workflows/
You can also explore and upload workflows via the Package Registry UI.
We won't actually consume an external workflow in this tutorial, but this is how you'd do it.
5. Add a module and a transition
A module is a Go package that holds transitions — the action handlers that workflow states invoke.
kue add module -n hello -o .
kue add transition -n hello/Hello -m hello -o .
This creates modules/hello/transitions/hello.go. Replace its contents with the
minimal "Hello World" implementation:
package transitions
import (
"github.com/kuetix/engine/engine/domain"
"github.com/kuetix/engine/engine/domain/interfaces"
"github.com/kuetix/engine/engine/workflow"
)
type helloTransitions struct {
workflow.BaseServiceTransition
}
func NewHelloTransitions() interfaces.ServiceTransitions {
return &helloTransitions{}
}
func (h *helloTransitions) Hello() (r domain.FlowStepResult) {
r.Success = true
r.Response = "Hello World"
return
}
6. Write the workflow
Create workflows/hello_world.wsl:
module hello_world
workflow startup {
start: SayHello
state SayHello {
action hello/hello.Hello() as Result
on success -> Finish
on fail -> Failed
}
state Finish {
action services/common/response.Response(
value: "<<Result.response??Result??'Hello'>>",
statusCode: 200
) as FinalResult
end ok
}
state Failed {
action services/common/response.Response(
value: "Failed to say hello",
statusCode: 500
) as ErrorResult
end fail
}
}
:::tip Use the <<...>> template form for dynamic values
Plain $Result.response as an argument value does not resolve in this
context — the engine reports missed arguments: [value]. The template form
"<<Result.response??Result??'Hello'>>" tries Result.response, then Result,
then falls back to a default string. Use it whenever you need to forward a
previous state's output as a transition argument.
:::
7. Enable std-core in your modules
Open modules/modules.go and enable std-core so that
services/common/response.Response is registered with the engine:
package modules
import (
di "github.com/kuetix/container"
stdCoreModules "github.com/kuetix/std-core/modules"
)
func init() { di.Boot() }
func Enable() { stdCoreModules.Enable() }
8. Resolve dependencies
The first time you build, you'll need to wire local checkouts for
engine and std-core until they're published to a public Go proxy:
kue update # generates modules/{di,meta}.go + modules.json
go mod edit -replace github.com/kuetix/engine=$HOME/Projects/kuetix/engine
go mod edit -replace github.com/kuetix/std-core=$HOME/Projects/kuetix/packages/core
go mod tidy
go mod vendor
:::note Why the replace directives?
- The published
engineon the Go proxy is missing theengine/engine/*packages that the local checkout has. std-coreis a private sibling repo, not yet on a public proxy.
Once both modules are published you can drop the replace directives.
:::
9. Run the CLI
Replace cmd/cli/main.go with the version below. The two important details are
the @ prefix on the workflow name and RestartPolicy: "stop".
package main
import (
"flag"
"fmt"
"os"
"strings"
"hello_world/modules"
"github.com/kuetix/engine"
"github.com/kuetix/engine/engine/domain"
)
var Version string
var BuildTime string
func main() {
if len(os.Args) < 2 {
fmt.Printf("Usage: %s <workflow_name> -[v|verbose]\n", os.Args[0])
os.Exit(1)
}
workflow := os.Args[1]
if !strings.HasPrefix(workflow, "@") {
workflow = "@" + workflow
}
os.Args = os.Args[1:]
verbose := flag.Bool("verbose", false, "Verbose mode")
vFlag := flag.Bool("v", false, "Verbose mode")
flag.Parse()
modules.Enable()
verboseMode := *verbose || *vFlag
options := &domain.Options{
EngineName: "hello_world-cli",
ConfigName: "hello_world",
Verbose: verboseMode,
Quiet: !verboseMode,
Amount: 1,
Retry: 1,
RetryDelay: 0,
RestartPolicy: "stop",
Workflow: workflow,
Version: Version,
BuildTime: BuildTime,
LogPath: "stdout",
Config: &domain.Config{},
Args: flag.Args(),
}
response := engine.RunWorkflow("production", options)
for _, res := range response {
if res == nil {
continue
}
if res.Response != nil {
fmt.Printf("Result: %v\n", res.Response)
}
if res.Error != nil {
fmt.Printf("Error: %s\n", res.Error)
os.Exit(1)
}
}
}
:::caution The @ prefix and RestartPolicy matter
@prefix: without it, the engine searches relative to the working dir, not underworkflows/. With it, candidate paths includeworkflows/<name>.wsl.RestartPolicy: "stop": the default"on-failure"makes the engine silently retry forever on any error (including "workflow not found"). With"stop", errors surface immediately. :::
Build and run:
make build
./runtime/bin/hello_world-cli hello_world
# → Result: Hello World
10. Add an HTTP endpoint
Now serve the same workflow over HTTP using std-http. Wire the local
checkout:
go mod edit -require github.com/kuetix/std-http@v0.0.0
go mod edit -replace github.com/kuetix/std-http=$HOME/Projects/kuetix/packages/http
go mod tidy
Copy the api_server solution into the project so you can override
routes.wsl:
mkdir -p workflows/solutions/api_server
cp -r $HOME/Projects/kuetix/packages/http/workflows/solutions/api_server/* \
workflows/solutions/api_server/
:::note Why copy instead of import?
The engine's file resolution prefers EmbedFSRootPath candidates first, so an
embedded routes.wsl would always win over a local override. Copying the
solution into your project gives you ownership of the routing table.
:::
Replace workflows/solutions/api_server/routes.wsl with a single route to your
workflow:
const {
routes: {
"/hello_world": [
{
"method": "GET",
"workflow": "@hello_world",
"description": "GET Hello World endpoint",
"require": {}
},
],
}
}
Add cmd/api/main.go:
package main
import (
"flag"
"fmt"
"os"
"strings"
stdHttpModules "github.com/kuetix/std-http/modules"
"hello_world/modules"
"github.com/kuetix/engine"
"github.com/kuetix/engine/engine/domain"
)
var Version string
var BuildTime string
func main() {
workflow := "@solutions/api_server/startup"
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
workflow = os.Args[1]
os.Args = os.Args[1:]
}
verbose := flag.Bool("verbose", false, "Verbose mode")
vFlag := flag.Bool("v", false, "Verbose mode")
flag.Parse()
modules.Enable()
stdHttpModules.Enable()
verboseMode := *verbose || *vFlag
if BuildTime == "" {
BuildTime = "unknown"
}
response := engine.RunWorkflow("production", &domain.Options{
Version: Version,
BuildTime: BuildTime,
EngineName: "hello_world-api",
ConfigName: "hello_world",
Verbose: verboseMode,
Quiet: !verboseMode,
Amount: 1,
Retry: 1,
RetryDelay: 0,
RestartPolicy: "stop",
Workflow: workflow,
LogPath: "stdout",
Config: &domain.Config{},
Args: flag.Args(),
})
for _, res := range response {
if res == nil {
continue
}
if res.Response != nil {
fmt.Printf("Result: %v\n", res.Response)
}
if res.Error != nil {
fmt.Printf("Error: %s\n", res.Error)
os.Exit(1)
}
}
}
Rebuild the module cache, vendor, and build the API binary:
go mod vendor
kue update
make build-api
11. Serve and verify
./runtime/bin/hello_world-api &
# Starting API server on :9999
# All endpoints:
# - GET /hello_world → @hello_world
curl -s http://localhost:9999/hello_world
# → {"success":true,"data":"Hello World"}
The default port is 9999, set in startup.wsl via port: $args.port??"9999".
To override it, pass port=8080 as a positional arg — the engine parses
key=value strings from Args:
./runtime/bin/hello_world-api @solutions/api_server/startup port=8080
Recap
You now have two binaries built on a single workflow:
./runtime/bin/hello_world-cli hello_world
# → Result: Hello World
curl -s http://localhost:9999/hello_world
# → {"success":true,"data":"Hello World"}
Both call workflows/hello_world.wsl, which dispatches to
modules/hello/transitions/hello.go::Hello() and emits via
services/common/response.Response. The HTTP path stacks
solutions/api_server (from std-http) on top as a routing layer.
Gotchas to remember
| Symptom | Cause | Fix |
|---|---|---|
| Binary hangs forever, no output | RestartPolicy: "on-failure" retries silently | Set RestartPolicy: "stop" |
workflow not found despite file in workflows/ | Missing @ prefix on workflow name | Prepend @ (e.g. @hello_world) |
missed arguments: [value] from Response | $Result.response doesn't resolve as a direct argument | Wrap as "<<Result.response??Result??'Hello'>>" |
Stale binary after make install | make install doesn't depend on make cli | Always run make cli && make install |
Embedded routes.wsl from std-http wins | Engine prefers EmbedFSRootPath candidates | Copy solutions/api_server into your project |
Next steps
- WSL Documentation — master the workflow language.
- Examples — more complex workflow patterns.
- Package Registry — browse and publish workflows.
- CLI Reference — every
kuecommand and flag.