Go Step Generator
Generate an Argyll Step contract and HTTP adapter from an ordinary Go function.
Installation
The generator ships with the Go SDK:
go get github.com/kode4food/argyll/sdk/go
Quick Start
For a standalone Step server, use package main, pass -server before the package pattern, and add a directive to a function:
package main
//go:generate go run github.com/kode4food/argyll/sdk/go/gen/cmd/argyll-gen -server .
type RiskArgs struct {
CustomerID string
Amount int64
}
type RiskResult struct {
Score int
Approved bool
}
//argyll:step
func CalculateRisk(args RiskArgs) (RiskResult, error) {
return RiskResult{
Score: int(args.Amount / 100),
Approved: args.Amount < 10_000,
}, nil
}
Run go generate ./..., then build or run the package normally. The generated zz_argyll_gen.go contains main, registers every Step in the package, and serves their handlers. Each invocation is logged through the default slog logger with its Step ID.
-server requires package main and rejects a package that already declares main. The function can live in zz_argyll_gen.go; Go does not require it to be in a file named main.go.
The generated server reads ARGYLL_ENGINE_URL, STEP_HOSTNAME, and STEP_PORT from the environment.
Custom Server
Leave off -server when the application owns its startup, HTTP server, or logging:
//go:generate go run github.com/kode4food/argyll/sdk/go/gen/cmd/argyll-gen ./...
Normal generation emits ArgyllSteps() without importing slog or wrapping invocations with generated logging. Pass those definitions to gen.Serve, or use gen.Register and gen.Mux to own the HTTP server yourself:
import "github.com/kode4food/argyll/sdk/go/gen"
func main() {
if err := gen.Serve(context.Background(), ArgyllSteps()...); err != nil {
log.Fatal(err)
}
}
Directives
Every generated Step starts with one primary directive in the function’s doc comment:
| Directive | Function shape |
|---|---|
//argyll:step | Zero arguments or one argument struct; zero results or one result struct; optional trailing error |
//argyll:wrap | Ordinary positional arguments and results |
Additional directives in that same doc comment configure the Step:
| Directive | Configuration |
|---|---|
//argyll:memoize | Memoized handling |
//argyll:compensate Refund | Compensated handling through Refund |
//argyll:predicate return args.amount > 0 | Execution predicate |
//argyll:work max_retries:3 | Retry and concurrency settings |
//argyll:http timeout:2500 | HTTP invocation settings |
//argyll:labels domain:risk | Step labels |
Directives and field tags share one option format: a leading value names the subject, and semicolon-separated key:value properties configure it. Whitespace around values and separators is optional. For example, this declares the Step ID charge-card-v2 and the display name Charge Card (v2):
//argyll:step charge-card-v2;name:Charge Card (v2)
Currency string `argyll:"iso_currency;role:optional;default:USD"`
On a struct field, the leading value overrides the inferred Attribute name. This tag names the Attribute iso_currency, assigns the optional role, and gives it the default USD.
memoize is a marker, while compensate names one function; the two handling modes are mutually exclusive. http, work, and labels accept properties and may be repeated across lines.
An omitted ID is the function name in kebab-case. IDs use lowercase letters and digits, separated by hyphens.
The optional name property follows the ID on //argyll:step or //argyll:wrap, defaulting to the function name in Title Case.
//argyll:step
A Step function takes zero arguments or one argument struct, named or anonymous. It returns nothing, one outputs struct, an error, or an outputs struct followed by an error. Its function type must be one of:
func()
func() Result
func() (Result, error)
func() error
func(Args)
func(Args) Result
func(Args) error
func(Args) (Result, error)
Fields of the argument struct become Step inputs, and fields of the result struct become Step outputs. Zero-argument functions have no inputs; functions without a result struct have no outputs.
//argyll:wrap
Use //argyll:wrap with an ordinary positional function. Its full declaration has this shape; square brackets mark optional parts and are not written literally:
//argyll:wrap [step-id] [(input, ...)] [-> (output, ...)] [;name:Display Name]
The lists name Attributes in the same order as the function’s parameters and non-error results. The -> separates inputs from outputs, and a trailing error is never an output Attribute. Omitted lists are inferred from named Go parameters or results, while () explicitly declares an empty side.
With both sides named in Go, the bare directive can infer the complete contract and convert names to snake_case:
//argyll:wrap
func CalculateRisk(
customerID string, amount int64,
) (score int, approved bool, err error)
That yields inputs customer_id and amount, plus outputs score and approved.
Unnamed results need an output list. Here the omitted input list is inferred from the named parameters, while (score, approved) names the two results:
//argyll:wrap -> (score, approved)
func CalculateRisk(customerID string, amount int64) (int, bool, error)
Names supplied by the directive are used verbatim instead of being converted to snake_case. This complete declaration sets the Step ID to score-v2, overrides both input names, and names both results:
//argyll:wrap score-v2(customer-id, amount) -> (score, approved)
func CalculateRisk(customerID string, amount int64) (int, bool, error)
For a source function with no parameters, () -> (score) explicitly declares no inputs and names its one output:
//argyll:wrap () -> (score)
func CurrentScore() int
The generator checks the declared names against the function signature at build time and reports mismatches at their source position. The wrapped function remains an ordinary Go function.
//argyll:predicate
Declares the script that gates the Step, defaulting to Lua:
//argyll:step
//argyll:predicate return args.amount > 0
func ChargeCard(args ChargeArgs) (ChargeResult, error)
Optionally prefix the script with lua: or jpath: to select its language:
//argyll:predicate jpath:$.items[?(@.status=="ready")]
Any other prefix is part of the default Lua script.
//argyll:work
Configures retries and Work Item concurrency. It is repeatable:
//argyll:work max_retries: 3; init_backoff: 100; max_backoff: 5000
//argyll:work backoff_type: exponential; parallelism: 4
| Property | Effect |
|---|---|
backoff_type | Retry backoff: fixed, linear, or exponential |
max_retries | Maximum retries; -1 retries without a limit |
init_backoff | Initial retry delay in milliseconds |
max_backoff | Maximum retry delay in milliseconds |
parallelism | Maximum concurrent Work Items |
//argyll:memoize
Marks a Step as memoized:
//argyll:step
//argyll:memoize
func CalculateRisk(args RiskArgs) (RiskResult, error)
The engine caches successful results for matching inputs. //argyll:memoize and //argyll:compensate select mutually exclusive handling modes.
//argyll:compensate
Names the function that reverses a successful Step invocation:
type ChargeArgs struct {
OrderID string `argyll:"compensated:true"`
}
type ChargeResult struct {
ChargeID string `argyll:"compensated:true"`
}
type RefundArgs struct {
ChargeID string
}
//argyll:step
//argyll:compensate Refund;timeout:5000
func Charge(args ChargeArgs) (ChargeResult, error)
func Refund(args RefundArgs) error
A //argyll:step compensator takes zero arguments or one argument struct. Every field in that struct must match an Attribute marked compensated:true on the Step, using its invocation name, but the struct may omit compensated Attributes it does not use. The engine still sends every compensated Attribute and the generated codec ignores the extras, so adding another compensated Attribute does not break an existing compensator.
When a compensator wants everything the Step took and returned, it can embed both structs rather than restating their fields, since embedding flattens. Every field it pulls in has to be a compensated Attribute, so this fits a Step whose inputs are compensated whole:
type RefundArgs struct {
ChargeArgs
ChargeResult
}
A //argyll:wrap compensator takes named positional arguments matching the wrapped Step Attributes it consumes. Those arguments select the Attributes for compensation, and the generator builds the private flat struct and codec used by the HTTP handler. A compensator returns either nothing or an error.
The generator validates the signature and serves the function at POST /<step-id>/compensate. The optional timeout property overrides the invocation timeout for compensation; without it, compensation inherits the Step’s timeout. The directive selects compensated handling; referencing the function does not register it as a Step unless it has its own //argyll:step or //argyll:wrap directive.
//argyll:http
Configures the generated Step’s HTTP invocation:
//argyll:step charge-card;name:Charge Card
//argyll:http timeout: 2500
func ChargeCard(args ChargeArgs) (ChargeResult, error)
| Property | Effect |
|---|---|
timeout | Invocation timeout in milliseconds |
The engine enforces timeout around the invocation. Generated Steps serve their synchronous handlers through POST; the generator derives their endpoint from the Step ID.
//argyll:labels
Step labels, in the same repeatable form:
//argyll:step
//argyll:labels description: score a customer for risk
//argyll:labels domain: risk; tier: gold
func CalculateRisk(args RiskArgs) (RiskResult, error)
Contract Inference
By default, field names map to attribute names as snake_case. An argyll field tag overrides the default per field:
| Go field | Attribute | Type |
|---|---|---|
CustomerID string | customer_id | string |
HTTPServer string | http_server | string |
Amount int64 | amount | number |
Approved bool | approved | boolean |
Tags []string | tags | array |
Limits map[string]int | limits | object |
Address Address | address | object |
Note *string | note | string, optional |
Currency string `argyll:"iso_currency"` | iso_currency | string |
The function name becomes the Step identity, as kebab-case: func CalculateRisk registers as ID calculate-risk, name Calculate Risk.
Field Tags
An argyll struct tag overrides that default and names the attribute explicitly:
type EnrollArgs struct {
Currency string `argyll:"iso_currency"`
Scratch string `argyll:"-"`
}
A tag of - keeps the field out of the contract and off the wire entirely, so it stays available as ordinary Go state. Unexported fields are skipped the same way.
The tag applies wherever the struct appears, in inputs, in outputs, and at any nesting depth. The generator rejects a property it does not know, reporting the file, line, and field.
Embedded Structs
An embedded struct flattens, so its fields become Attributes of the outer struct rather than one nested object:
type ChargeArgs struct {
OrderID string `argyll:"compensated:true"`
}
type ChargeResult struct {
ChargeID string `argyll:"compensated:true"`
}
type RefundArgs struct {
ChargeArgs
ChargeResult
}
RefundArgs carries the Attributes order_id and charge_id at the top level, the same shape Go promotion gives it, so args.OrderID reads the field the wire delivered. Flattening applies at any depth and keeps each field’s tag.
Any argyll tag on the embedded field opts out of flattening, making it one nested object Attribute like an ordinary field, named by the tag or by the embedded type. A tag of - drops it entirely:
type RefundArgs struct {
ChargeArgs `argyll:"charge"`
}
Two flattened fields that collide are an error, reported as an ambiguous embedded field. Fields collide when they share an Attribute name, which would put two values on one name, or when they share a Go field name, which Go itself cannot resolve through promotion.
Attribute Properties
Properties after the name configure the attribute. Leave the name off to keep the snake_case default and still set properties:
type ChargeArgs struct {
OrderID string `argyll:"for_each:true;collect:all"`
Note string `argyll:"role:optional"`
Currency string `argyll:"role:optional;default:USD;deadline:5000"`
Gateway string `argyll:"role:const;value:stripe"`
FlowID string `argyll:"flow;role:meta;key:flow_id"`
Amount int64 `argyll-match:"lua:return args.amount > 0"`
Receipt string `argyll:"compensated:true"`
}
| Property | Effect |
|---|---|
role | required, optional, const, or meta, defaulting to required for a value field and optional for a pointer |
default | Default value of an optional input |
value | Fixed value of a const input |
key | Execution metadata key filling a meta input |
collect | first, last, all, some, or none |
deadline | Collection deadline of an optional input, in milliseconds |
for_each | true to expand the attribute into one Work Item per element |
mapping | Name the attribute is mapped to |
compensated | true to include the Attribute in compensation requests |
Each property belongs to an Attribute role. Validation reports both the property and required role: default uses optional, value uses const, key uses meta, and mapping uses an input or output.
default and value reach the engine as JSON, and the generator quotes the value of a string attribute for you, so default:USD is written plainly.
Match and mapping scripts use separate tags so their contents, including semicolons, remain opaque:
Value string `argyll-match:"jpath:$.ready" argyll-mapping:"lua:x = value; return x"`
Both tags use [language:]script, where the optional known language is lua or jpath. argyll-match defaults to JPath; argyll-mapping defaults to Lua. A match needs a required input, while a mapping script needs an input or output.
A for_each attribute is declared as an array and arrives one element at a time, so the Go field carries the element type: OrderID string above declares order_id as an array and receives a single order ID per Work Item.
An output can set its name, mapping, argyll-mapping script, and compensation flag. Fields nested inside an arguments or outputs struct are values within an attribute rather than attributes themselves, so only their name applies.
Attribute Types
All numeric types are number. A pointer field is an optional attribute. Structs and maps nest to any depth, including recursively:
type Node struct {
Name string
Children []Node
Next *Node
}
A type that reaches itself, through a slice, a pointer, a map, or a chain of other structs, gets a codec built during package initialization so it can refer to itself. Trees arrive and leave at whatever depth the payload carries.
Failures
An error is control plane information, never an output attribute. Returned errors and recovered panics both become Step failures over the existing protocol, and stay distinguishable: an error responds 422 with problem details, a panic responds 500 and logs the panic value with its stack.
To choose the status yourself, return an *argyll.HTTPError, the same type hand written handlers use:
return argyll.NewHTTPError(http.StatusNotFound, "no such customer")
Argyll treats every non-2xx response as a failure regardless, so the status is for your own operators and traces.
Codecs
Argyll’s wire format is JSON, but the Step contract is not. The generator resolves each Go type into a composition of codecs from the codec package, which read and write through encoding/json/jsontext:
codec.Struct(
codec.Field("customer_id", codec.Text[string](), func(v *RiskArgs) *string {
return &v.CustomerID
}),
codec.Field("tags", codec.Slice(codec.Text[string]()), func(v *RiskArgs) *[]string {
return &v.Tags
}),
)
There is no reflection at runtime, and no bespoke parser per function. Text, Number, Boolean, Slice, Optional, Map, and Struct compose to cover the supported types. A field type outside that set fails generation with its position and the offending type.
Validation
The generator assembles an api.Step and applies the engine’s validation rules during go generate. Validation errors include the source file and line, covering cases such as an unknown collect value, for_each on a scalar, an invalid JSON default, or a URL placeholder that has no required input.
The generated file carries the validated Step in the same wire form the engine receives. Registration adds the Step server’s reachable host and submits that definition unchanged.
Generated Output
Each package gets one zz_argyll_gen.go. In normal mode, its only package-level symbol is ArgyllSteps(); generated codecs, adapter types, and Step definitions stay inside that function so they cannot collide with application names.
With -server, the generated package-level symbol is main instead. Step definitions and codecs remain local to main, so ArgyllSteps() does not pollute the package namespace. Generated handlers are wrapped with invocation logging, then passed to gen.Serve for registration and serving.
One go:generate line is enough for a whole tree in normal mode. The generator scans every file of every package matching the pattern, so ./... reaches the directive’s own package and everything beneath it, writing a file into each package that declares Steps. Use a package-specific pattern such as . with -server, because every matched package that declares Steps must be a package main. Extra go:generate lines are safe: a second pass over the same package produces the same bytes and leaves the file alone.
Treat it as a build artifact, gitignored and rebuilt by your generate target, so it always matches the directives and the generator. Run go generate ./... ahead of go build in your Makefile and container builds.
Choosing Between the Generator and the Builder
Use the generator when the Go function owns the Step contract: its signature defines the Attributes, and directives add predicates, Work Item configuration, HTTP settings, labels, and handling. Use the builder to register existing HTTP services, Script Steps, async Steps, Flow Steps, and to start Flows. Both paths produce the same Step definition and can be used together in one package.