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:

DirectiveFunction shape
//argyll:stepZero arguments or one argument struct; zero results or one result struct; optional trailing error
//argyll:wrapOrdinary positional arguments and results

Additional directives in that same doc comment configure the Step:

DirectiveConfiguration
//argyll:memoizeMemoized handling
//argyll:compensate RefundCompensated handling through Refund
//argyll:predicate return args.amount > 0Execution predicate
//argyll:work max_retries:3Retry and concurrency settings
//argyll:http timeout:2500HTTP invocation settings
//argyll:labels domain:riskStep 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
PropertyEffect
backoff_typeRetry backoff: fixed, linear, or exponential
max_retriesMaximum retries; -1 retries without a limit
init_backoffInitial retry delay in milliseconds
max_backoffMaximum retry delay in milliseconds
parallelismMaximum 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)
PropertyEffect
timeoutInvocation 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 fieldAttributeType
CustomerID stringcustomer_idstring
HTTPServer stringhttp_serverstring
Amount int64amountnumber
Approved boolapprovedboolean
Tags []stringtagsarray
Limits map[string]intlimitsobject
Address Addressaddressobject
Note *stringnotestring, optional
Currency string `argyll:"iso_currency"`iso_currencystring

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"`
}
PropertyEffect
rolerequired, optional, const, or meta, defaulting to required for a value field and optional for a pointer
defaultDefault value of an optional input
valueFixed value of a const input
keyExecution metadata key filling a meta input
collectfirst, last, all, some, or none
deadlineCollection deadline of an optional input, in milliseconds
for_eachtrue to expand the attribute into one Work Item per element
mappingName the attribute is mapped to
compensatedtrue 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.