Skip to main content

Building Custom Nodes in Go

This guide walks through creating a first-class node type in Go — the same mechanism every built-in node (http, llm, slack-send, …) uses. A Go node gets full access to the runtime: streaming, sandboxing, OAuth tokens, the workspace store, and native performance.

Don't need Go? If your node is "call an HTTP API with my config values", you can define it declaratively with zero code — see Declarative custom nodes at the end. Go nodes are for everything else: SDK clients, binary protocols, local computation, multi-call orchestration.

How execution works

Understanding the pipeline makes the interface obvious:

  1. The workflow engine (Temporal) schedules each DSL step as an activity.
  2. The activity dispatcher (internal/execution/activities.go) looks up the step's type in the global node registry.
  3. Before your node runs, every {{ expression }} in the step's config is resolved against trigger, steps.* and secret.* — your Execute receives final values.
  4. Your node returns an output map. The engine persists it, and downstream steps reference it as {{ steps.<id>.<key> }}.
  5. Failures (returned errors) are recorded on the step and retried per the workflow's Temporal retry policy. Durability, resume-after-crash and exactly-once semantics come for free — your node just does its work.
DSL step ──▶ Temporal activity ──▶ registry.Get(type) ──▶ resolve {{…}} ──▶ YourNode.Execute() ──▶ output map

Step 1 — Implement the Node interface

Every node implements two methods (internal/nodes/node.go):

type Node interface {
// Type returns the unique DSL identifier (e.g. "http", "text-stats").
Type() string
// Execute runs the node and returns the output map.
Execute(ctx context.Context, config map[string]any, inputs map[string]any) (map[string]any, error)
}

Here is a complete, runnable example — a text-stats node that analyzes a piece of text. A copy of this file lives at examples/custom-node/textstats.go so you can start from working code:

package core // place the file in internal/nodes/core/ (see Step 2)

import (
"context"
"fmt"
"strings"
"unicode"

"github.com/goagents/platform/internal/nodes"
)

func init() {
nodes.GlobalRegistry.Register(&TextStatsNode{})
}

// TextStatsNode counts words, sentences and characters in a text.
type TextStatsNode struct{}

func (n *TextStatsNode) Type() string { return "text-stats" }

func (n *TextStatsNode) Execute(ctx context.Context, config map[string]any, inputs map[string]any) (map[string]any, error) {
// 1. Read config. Values arrive with {{ expressions }} already resolved,
// but may be string OR native types depending on how the user filled
// the field — always coerce defensively.
text, _ := config["text"].(string)
if strings.TrimSpace(text) == "" {
return nil, fmt.Errorf("text-stats: \"text\" is required")
}
minWordLen := 0
switch v := config["min_word_length"].(type) {
case float64: // JSON numbers decode as float64
minWordLen = int(v)
case int:
minWordLen = v
}

// 2. Honour ctx — long-running work should check ctx.Done() so Temporal
// can cancel/timeout the activity cleanly.

// 3. Do the work.
words := strings.FieldsFunc(text, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) })
counted := 0
longest := ""
for _, w := range words {
if len(w) >= minWordLen {
counted++
}
if len(w) > len(longest) {
longest = w
}
}
sentences := strings.Count(text, ".") + strings.Count(text, "!") + strings.Count(text, "?")

// 4. Return a flat, JSON-serializable output map. These keys are what
// downstream steps reference: {{ steps.<id>.words }} etc.
return map[string]any{
"words": counted,
"sentences": sentences,
"characters": len(text),
"longest_word": longest,
}, nil
}

Conventions that matter

ConventionWhy
Coerce config values — accept both native types and stringsTextarea fields submit strings; the AI generator may emit native JSON. Use the asObject / asArray helpers in internal/nodes/core/helpers.go for structured fields.
Return error for failuresThe step is marked failed with your message, surfaces in the execution detail UI, and is retried by Temporal. Never panic.
Flat, JSON-serializable outputOutput is persisted to PostgreSQL and resolved by {{ steps.id.key }}. Avoid channels, funcs, or huge binary blobs (use the workspace store for files).
Respect ctxCancellation and activity timeouts flow through it. Check ctx.Done() in loops; pass it to HTTP calls.
No global mutable stateThe same node instance is shared across concurrent executions on the worker. Keep per-run state in locals.
Read upstream data via config, not inputsUsers wire data with {{ steps.x.y }} in config fields. inputs["trigger"] / inputs["steps"] exist for advanced nodes that need raw access.

Step 2 — Register it

Nodes self-register via init() (already in the example above):

func init() {
nodes.GlobalRegistry.Register(&TextStatsNode{})
}

Registration requires the package to be imported:

  • Easiest: drop your file in internal/nodes/core/ — that package is already blank-imported by both binaries.
  • Own package (e.g. internal/nodes/mycompany/): add a blank import to both entrypoints, since the API validates types and the worker executes them:
// cmd/api/main.go AND cmd/worker/main.go
_ "github.com/goagents/platform/internal/nodes/mycompany"

Rebuild and restart (./start.sh does both). The engine can now execute "type": "text-stats" steps.

Step 3 — Make it appear in the editor

The frontend needs to know how to render your node. Four registration points, all in web/src/:

3a. Config panel fields — lib/utils/nodeSchemas.tsNODE_SCHEMAS

{
type: 'text-stats',
label: 'Text Stats',
description: 'Count words, sentences and characters in a text',
color: '#10B981',
icon: 'type',
fields: [
{ name: 'text', label: 'Text', type: 'textarea', required: true,
placeholder: '{{ steps.previous.output }}',
hint: 'The text to analyze — insert upstream data with the { } Data button' },
{ name: 'min_word_length', label: 'Min word length', type: 'number', default: 0 },
],
}

Field types: text, textarea, code, number, select (+options), password (renders the secrets picker), multicheck, plus special pickers (model-picker, kb-picker, workflow-picker, skill-picker, oauth-google|notion|slack).

3b. Palette entry — routes/workflows/[id]/+page.sveltebuiltinSections

Add the type slug to a group so users can drag it onto the canvas:

'Transform': ['transform', 'set', 'code', 'text-stats'],

3c. Canvas card color + icon — lib/components/canvas/WorkflowNode.svelteTYPE_CFG

'text-stats': { border: '#34d399', bg: '#f0fdf4', Icon: Type },

border drives the node's accent in both themes (dark surfaces are derived automatically via color-mix). Icon is any lucide component. For brand logos instead, add a simpleicons slug to BRAND_SLUGS or a local SVG path to LOCAL_BRAND_ICONS in nodeSchemas.ts — the NodeIcon component handles fallback chains automatically.

3d. Output variables for the { } Data picker — nodeSchemas.tsNODE_OUTPUT_VARS

This is what makes your node's outputs click-to-insert in downstream nodes (autocomplete on {{, the { } Data button, and the Available Data chips):

'text-stats': [
{ label: 'Word count', path: 'words', sample: '42' },
{ label: 'Sentences', path: 'sentences', sample: '3' },
{ label: 'Characters', path: 'characters', sample: '256' },
{ label: 'Longest word', path: 'longest_word', sample: '"extraordinary"' },
],

Nodes without an entry still work — the picker falls back to generic output / result paths.

Step 4 (optional) — Expose it as an AI agent tool

Implement ToolDescriber and the same node becomes callable by agent nodes — one implementation, two surfaces:

func (n *TextStatsNode) ToolDef() nodes.NodeToolDef {
return nodes.NodeToolDef{
Name: "text_stats",
Desc: "Count words, sentences and characters in a text. Use to measure or compare text lengths.",
Params: map[string]nodes.NodeToolParam{
"text": {Desc: "The text to analyze", Type: "string", Required: true},
"min_word_length": {Desc: "Only count words at least this long", Type: "integer"},
},
}
}

The LLM sees Desc and Params as the tool schema; tool calls are routed to your Execute with the params as config. To make it draggable as a standalone Tool node card (attached to an Agent's tool port), also add a TOOL_SCHEMAS entry in nodeSchemas.ts and a TYPE_CFG entry in ToolNode.svelte.

Custom triggers in Go

Triggers are a different axis — they start workflows rather than run inside them:

  • Webhook-style (GitHub, Stripe, …): events arrive at POST /webhooks/{workflowId}; the payload becomes {{ trigger.body.* }}. A new webhook trigger type usually needs no backend code — add it to TRIGGER_TYPES / TRIGGER_CONFIG_FIELDS / TRIGGER_OUTPUT_VARS in nodeSchemas.ts (icon + fields + output docs), or just create it declaratively in Custom Nodes with kind = Trigger.
  • Polling triggers (email, Airtable, …): implemented as scheduled poller activities — see internal/execution/ pollers and internal/api/triggers.go for the pattern.

Testing your node

Execute is a pure function of (config, inputs) — unit test it directly, no Temporal needed:

func TestTextStats(t *testing.T) {
n := &TextStatsNode{}
out, err := n.Execute(context.Background(),
map[string]any{"text": "Hello world. How are you?"},
map[string]any{},
)
if err != nil { t.Fatal(err) }
if out["words"] != 5 { t.Errorf("words = %v, want 5", out["words"]) }
}
go test ./internal/nodes/core/ -run TestTextStats -v

For an end-to-end check: rebuild, create a workflow with your step type via the canvas (or POST /api/v1/workflows), run it, and inspect the step output in the execution detail page.

Checklist

WhereWhat
internal/nodes/<pkg>/yournode.goNode impl + init() registration
cmd/api/main.go + cmd/worker/main.goblank import (only for new packages)
nodeSchemas.tsNODE_SCHEMASlabel, description, config fields
nodeSchemas.tsNODE_OUTPUT_VARSoutput paths for the data picker
editor builtinSectionspalette placement
WorkflowNode.svelteTYPE_CFGaccent color + lucide icon
(optional) ToolDef() + TOOL_SCHEMAS + ToolNode.svelteagent-tool surface
go testunit test for Execute

The no-code alternative: declarative custom nodes

For HTTP-shaped integrations, skip Go entirely: Custom Nodes (sidebar) or POST /api/v1/custom-nodes stores a JSON definition — label, icon, color, config fields, output variables, and an HTTP request template ({{ config.x }}, {{ secret.KEY }}, {{ steps.* }}). These appear in the palette instantly for your workspace with no rebuild, because the editor loads them from GET /api/v1/node-types at runtime and the worker resolves unknown custom-* types from the database.

Go nodeDeclarative node
Needs rebuild/redeployYesNo
Available toAll tenantsDefining workspace
ExecutionAnything Go can doOne templated HTTP request
Agent-tool supportYes (ToolDef)Yes (kind = tool)
Frontend work4 registration pointsNone — fully dynamic

Rule of thumb: prototype with a declarative node; graduate to Go when you need logic, SDKs, multiple calls, or want to ship it to every tenant.