Skip to main content
Register custom utility functions for use in workflows.

Overview

Functions are implemented in Go and exposed to the Otto JavaScript VM runtime in internal/functions/.

Function Registry

Functions are registered in internal/functions/otto_runtime.go:
func (r *OttoRuntime) registerFunctions() {
    // File functions
    r.vm.Set("fileExists", r.fileExists)
    r.vm.Set("fileLength", r.fileLength)
    r.vm.Set("readFile", r.readFile)

    // String functions
    r.vm.Set("trim", r.trim)
    r.vm.Set("split", r.split)

    // Add your function
    r.vm.Set("myNewFunction", r.myNewFunction)
}

Steps to Add a Function

1. Implement the Function

Add to an appropriate file in internal/functions/:
// internal/functions/util_functions.go

func (r *OttoRuntime) myNewFunction(call otto.FunctionCall) otto.Value {
    // Get arguments
    if len(call.ArgumentList) < 1 {
        return otto.UndefinedValue()
    }

    arg1 := call.Argument(0).String()

    // Optional second argument with default
    arg2 := "default"
    if len(call.ArgumentList) > 1 {
        arg2 = call.Argument(1).String()
    }

    // Perform operation
    result, err := doSomething(arg1, arg2)
    if err != nil {
        // Return undefined or error value
        r.vm.Set("_error", err.Error())
        return otto.UndefinedValue()
    }

    // Convert result to Otto value
    value, _ := r.vm.ToValue(result)
    return value
}

2. Register the Function

Update internal/functions/otto_runtime.go:
func (r *OttoRuntime) registerFunctions() {
    // ... existing registrations ...

    // Register your new function
    r.vm.Set("myNewFunction", r.myNewFunction)
}

3. Add to Function List

Update internal/functions/registry.go for ListFunctions():
func (r *Registry) ListFunctions() []FunctionInfo {
    return []FunctionInfo{
        // ... existing functions ...
        {
            Name:        "myNewFunction",
            Description: "Description of what it does",
            Signature:   "myNewFunction(arg1, arg2?)",
            Category:    "util",
        },
    }
}

4. Write Tests

Add to internal/functions/registry_test.go:
func TestMyNewFunction(t *testing.T) {
    registry := functions.NewRegistry()

    result, err := registry.Execute(`myNewFunction("input", "option")`, nil)

    require.NoError(t, err)
    assert.Equal(t, "expected-output", result)
}

func TestMyNewFunction_DefaultArg(t *testing.T) {
    registry := functions.NewRegistry()

    result, err := registry.Execute(`myNewFunction("input")`, nil)

    require.NoError(t, err)
    assert.Equal(t, "expected-with-default", result)
}

Return Types

String

func (r *OttoRuntime) myStringFunc(call otto.FunctionCall) otto.Value {
    result := "hello world"
    value, _ := r.vm.ToValue(result)
    return value
}

Boolean

func (r *OttoRuntime) myBoolFunc(call otto.FunctionCall) otto.Value {
    result := true
    value, _ := r.vm.ToValue(result)
    return value
}

Number

func (r *OttoRuntime) myNumberFunc(call otto.FunctionCall) otto.Value {
    result := 42
    value, _ := r.vm.ToValue(result)
    return value
}

Array

func (r *OttoRuntime) myArrayFunc(call otto.FunctionCall) otto.Value {
    result := []string{"a", "b", "c"}
    value, _ := r.vm.ToValue(result)
    return value
}

Object/Map

func (r *OttoRuntime) myObjectFunc(call otto.FunctionCall) otto.Value {
    result := map[string]interface{}{
        "key1": "value1",
        "key2": 42,
    }
    value, _ := r.vm.ToValue(result)
    return value
}

Working with Context

Functions can access the execution context:
func (r *OttoRuntime) myContextFunc(call otto.FunctionCall) otto.Value {
    // Get context variables
    ctxValue, err := r.vm.Get("_context")
    if err != nil {
        return otto.UndefinedValue()
    }

    ctx, _ := ctxValue.Export()
    contextMap := ctx.(map[string]interface{})

    target := contextMap["target"].(string)

    // Use context in function logic
    result := processWithContext(target)

    value, _ := r.vm.ToValue(result)
    return value
}

Example: Hash Function

// internal/functions/util_functions.go

import (
    "crypto/md5"
    "crypto/sha256"
    "encoding/hex"
)

func (r *OttoRuntime) hash(call otto.FunctionCall) otto.Value {
    if len(call.ArgumentList) < 2 {
        return otto.UndefinedValue()
    }

    input := call.Argument(0).String()
    algorithm := call.Argument(1).String()

    var result string
    switch algorithm {
    case "md5":
        hash := md5.Sum([]byte(input))
        result = hex.EncodeToString(hash[:])
    case "sha256":
        hash := sha256.Sum256([]byte(input))
        result = hex.EncodeToString(hash[:])
    default:
        return otto.UndefinedValue()
    }

    value, _ := r.vm.ToValue(result)
    return value
}
Register:
r.vm.Set("hash", r.hash)
Usage in workflow:
- name: compute-hash
  type: function
  function: hash("{{target}}", "sha256")
  exports:
    target_hash: "{{result}}"

Example: HTTP Fetch Function

// internal/functions/http_functions.go

func (r *OttoRuntime) httpFetch(call otto.FunctionCall) otto.Value {
    if len(call.ArgumentList) < 1 {
        return otto.UndefinedValue()
    }

    url := call.Argument(0).String()

    // Optional method (default GET)
    method := "GET"
    if len(call.ArgumentList) > 1 {
        method = call.Argument(1).String()
    }

    // Optional headers
    headers := make(map[string]string)
    if len(call.ArgumentList) > 2 {
        headersArg, _ := call.Argument(2).Export()
        if h, ok := headersArg.(map[string]interface{}); ok {
            for k, v := range h {
                headers[k] = fmt.Sprintf("%v", v)
            }
        }
    }

    // Make request
    req, err := http.NewRequest(method, url, nil)
    if err != nil {
        return otto.UndefinedValue()
    }

    for k, v := range headers {
        req.Header.Set(k, v)
    }

    client := &http.Client{Timeout: 30 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return otto.UndefinedValue()
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)

    result := map[string]interface{}{
        "status":  resp.StatusCode,
        "body":    string(body),
        "headers": resp.Header,
    }

    value, _ := r.vm.ToValue(result)
    return value
}
Usage:
- name: fetch-api
  type: function
  function: httpFetch("https://api.example.com/data", "GET", {"Authorization": "Bearer token"})
  exports:
    api_data: "{{result.body}}"

Best Practices

  1. Validate arguments - Check argument count and types
  2. Handle errors gracefully - Return undefined for errors
  3. Document the function - Add to ListFunctions()
  4. Write tests - Cover happy path and edge cases
  5. Keep functions pure - Minimize side effects
  6. Support optional arguments - Use sensible defaults

Function Categories

Organize functions by category:
CategoryFileFunctions
filefile_functions.gofileExists, fileLength, readFile, writeFile
stringstring_functions.gotrim, split, join, replace, contains
utilutil_functions.golog_info, getEnvVar, exit
httphttp_functions.gohttp_get, http_post
dbdb_functions.godb_select, db_select_assets
jqjq.gojq

Next Steps