Skip to main content

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_world created with kue create.
  • A hello/hello.Hello Go transition that returns "Hello World".
  • A workflows/hello_world.wsl workflow 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, and kuetix/packages/http repos (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:

modules/hello/transitions/hello.go
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:

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:

modules/modules.go
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 engine on the Go proxy is missing the engine/engine/* packages that the local checkout has.
  • std-core is 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".

cmd/cli/main.go
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 under workflows/. With it, candidate paths include workflows/<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:

workflows/solutions/api_server/routes.wsl
const {
routes: {
"/hello_world": [
{
"method": "GET",
"workflow": "@hello_world",
"description": "GET Hello World endpoint",
"require": {}
},
],
}
}

Add cmd/api/main.go:

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

SymptomCauseFix
Binary hangs forever, no outputRestartPolicy: "on-failure" retries silentlySet RestartPolicy: "stop"
workflow not found despite file in workflows/Missing @ prefix on workflow namePrepend @ (e.g. @hello_world)
missed arguments: [value] from Response$Result.response doesn't resolve as a direct argumentWrap as "<<Result.response??Result??'Hello'>>"
Stale binary after make installmake install doesn't depend on make cliAlways run make cli && make install
Embedded routes.wsl from std-http winsEngine prefers EmbedFSRootPath candidatesCopy solutions/api_server into your project

Next steps