Golang API

Aug 15, 2026 Β· 16 min read

Introduction

External agents in Nodlin communicate with the platform over NATS using JSON. They register the node types they own, receive actions and events, and issue commands back to Nodlin (store nodes, create links, raise events, run command sequences, and more).

To simplify that work, Nodlin provides a Go library β€” nodlinGo (github.com/johnha/nodlinGo/v2) β€” that covers:

  • Agent registration and lifecycle
  • Typed node specifications (actions, events, relationships)
  • Handler context for reading and updating nodes
  • Forms (vueform JSON for the web client)
  • Node images (SVG / PNG)
  • Checkpoints, links, out-of-context command sequences
  • Assignments, FYIs, activity reporting, and user notifications

Reference agents such as FRED and OpenAI show real usage of the same library.

Other language SDKs (for example TypeScript) may be provided later. The wire protocol is JSON over NATS, so a custom client is possible β€” but the protocol includes command IDs, checkpoints, and dependency tracking so responses can be ordered safely under parallel execution. Prefer the official library unless you have a strong reason to re-implement it. See propagation of change.
Availability of the Go library as a public open-source module may lag the platform beta. Contact Nodlin for access, module versions, and deployment guidance for your environment.

When to use an external Go agent

Nodlin supports several ways to define behaviour. Choose deliberately:

ApproachLanguageAccessBest for
External Go agentGo + nodlinGoExternal systems over NATS/JSONAPIs, AI, databases, multi-node synthesis, long-running work
External Python agentPythonSame external modelTeams standardising on Python
Internal agentGo (cluster)Direct store / internal busShared platform primitives (workflow, notes, files)
Script packageStarlarkSandboxed Nodlin API onlyFast domain packs without external I/O

Use external Go when you need to:

  • Call HTTP APIs, AI providers, or internal services
  • Keep secrets and network access outside the script sandbox
  • Run work that may take seconds or minutes
  • Own a set of node types that integrate tightly with those systems

Prefer scripts when logic is pure graph behaviour, forms, and visuals with no external calls.

Prefer internal workflow types (task, question, option, decision, …) when the concept is already a first-class platform type users know.

Behaviour is the same across agent kinds: typed nodes, forms, relationships, actions, and events. Only the API surface differs.


Core model: a reactive graph

Nodlin is a reactive knowledge graph. There is no central orchestrator that walks your business process.

  1. A node is stored (created or updated).
  2. Nodlin dispatches events to related dependents.
  3. Each dependent recomputes its own state and may store again.
  4. Change cascades through topology until the graph is quiet (consistency at quiescence).

Design implications:

  • Prefer a small number of meaningful types over deep custom control-flow code.
  • Edge direction is dependency direction: A β†’ B means A cares about B (A receives events when B changes). Physical or causal flow is often the reverse.
  • Do not invent a separate propagation engine. Relations + events are the engine.
  • Cycles are allowed; handlers should converge (skip StoreNode when nothing material changed).

Architecture overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     NATS / JSON      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  External Go    │◄────────────────────►│  Nodlin extAgent     β”‚
β”‚  agent process  β”‚  register, actions,  β”‚  (platform bridge)   β”‚
β”‚  (nodlinGo)     β”‚  events, callbacks   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                  β”‚
                                                     β–Ό
                                            Nodlin core / graphs

At startup the agent:

  1. Connects to the agent NATS bus.
  2. Publishes an ExtAgentSpec (manifest of types, actions, events, relations).
  3. Receives an external agent id and heartbeats.
  4. Subscribes to action/event subjects for its handlers.
  5. Calls back into Nodlin for stores, links, checkpoints, command sequences, etc.

All handler work is framed by a request context (user, graph, expanded related nodes, trace). In-context callbacks are only valid while that handler is running.


Getting started

Runtime configuration

Typical flags / environment variables (names may vary slightly by agent):

SettingPurpose
nodlin_agent_nats_server / NODLIN_AGENT_NATS_SERVERNATS address for external agents
hostname / HOSTNAMEInstance identity (often the pod name)
domain_agent_id / DOMAIN_AGENT_IDDomain agent registration id
Agent-specific secretsAPI keys, tokens β€” never hardcode

Canonical project layout

Follow a clean separation (as in the FRED agent):

~/nodlin/agents/<agent>/
  <agent>.go           # main: flags, ExtAgentSpec, NewNodlinAgent, shutdown
  go.mod               # module <agent>; require nodlinGo/v2 (+ nodlinImage if used)
  Dockerfile
  types/               # FQN constants + UDT structs
  images/ or util/     # agent and type icons
  <domainpkg>/         # handlers, forms, external clients
    agentTypes.go      # processor + StartProcessing(agent)
    createX.go
    deleteNode.go
    createXForm.go
  test/

Minimal main pattern

package main

import (
    "context"
    "os"
    "os/signal"
    "syscall"

    "github.com/namsral/flag"
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"

    nlGoAgent "github.com/johnha/nodlinGo/v2/agent"
    "github.com/johnha/nodlinGo/v2/nodlinAgent"
)

func main() {
    var (
        natsAddr      string
        logLevel      string
        hostname      string
        domainAgentID string
    )
    flag.StringVar(&natsAddr, "nodlin_agent_nats_server", "", "NATS JSON agent bus")
    flag.StringVar(&logLevel, "myagent_server_loglevel", "debug", "log level")
    flag.StringVar(&hostname, "hostname", "", "pod/host name")
    flag.StringVar(&domainAgentID, "domain_agent_id", "", "domain agent id")
    flag.Parse()

    level, err := zerolog.ParseLevel(logLevel)
    if err != nil {
        level = zerolog.DebugLevel
    }
    zerolog.SetGlobalLevel(level)

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    exitServer := make(chan os.Signal, 1)
    signal.Notify(exitServer, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

    processor, err := mypkg.NewProcessor(/* secrets from flags/env */)
    if err != nil {
        log.Fatal().Err(err).Msg("init processor")
    }

    spec := nodlinAgent.ExtAgentSpec{
        Agent:       "myagent",
        Version:     "v1",
        Description: "Short description of the agent",
        Image:       images.AGENT_ICON,
        Help:        "Markdown help for operators and users",
        Types:       []nodlinAgent.ExtAgentTypeSpec{ /* … */ },
        ExecUser:    "admin",
        ExecDomain:  "master",
    }

    terminated := make(chan struct{})
    errChan := make(chan error, 5)
    execAgent, err := nlGoAgent.NewNodlinAgent(
        ctx, &spec, natsAddr, hostname, domainAgentID, errChan, terminated,
    )
    if err != nil {
        log.Error().Err(err).Msg("register agent")
        cancel()
    }

    processor.StartProcessing(execAgent)

    for {
        select {
        case agErr := <-errChan:
            log.Error().Err(agErr).Msg("agent error")
            cancel()
        case <-exitServer:
            if execAgent != nil {
                execAgent.Shutdown()
            }
            cancel()
            return
        case <-ctx.Done():
            if execAgent != nil {
                execAgent.Shutdown()
            }
            return
        }
    }
}

Local development often uses go.work or replace directives to point at local nodlinGo / nodlinImage checkouts.


Agent specification

ExtAgentSpec

Top-level registration payload:

FieldRole
AgentShort name (no special characters), e.g. fred, openai
VersionAgent version string
Description / Help / ImageHuman-facing agent metadata
TypesSlice of ExtAgentTypeSpec
ExecUser / ExecDomainExecution identity (often admin / master for platform agents)

Validation expands short type and relation names to FQNs and initialises handler codes used on the wire.

Fully qualified names (FQN)

KindPatternExample
Typeagt_<domain>_<user>_<agent>_<name>agt_master_admin_fred_category
Relationagr_<domain>_<user>_<agent>_<name>agr_master_admin_fred_has_series
Propertyagp_<domain>_<user>_<agent>_<name>agp_master_admin_openai_title

Define constants in a types package β€” never scatter raw FQN strings through handlers.

const (
    FredDataCategory = "agt_master_admin_fred_category"
    FredDataSeries   = "agt_master_admin_fred_series"

    FredDataSubCategory = "agr_master_admin_fred_has_subcategory"
    FredDataHasSeries   = "agr_master_admin_fred_has_series"
)

ExtAgentTypeSpec

Every type should declare:

  • Name, user-facing Label, Description
  • Help (markdown) β€” short usage guide shown in the UI
  • Icon β€” type picker icon; reuse in form headers
  • Actions β€” at least create and delete
  • Optional lifecycle actions (see below)
  • Relations with human labels
  • Events the type reacts to
  • NoDuplicate: true when external side-state must not be copied; or implement duplicated to rebuild side-state after import
nodlinAgent.ExtAgentTypeSpec{
    Name:        types.FredDataCategory,
    Label:       "FRED Category",
    Description: "Category of FRED data",
    Icon:        images.FRED_LARGE_ICON,
    Help:        "FRED organises data series into hierarchical categories.",
    Actions: []*nodlinAgent.ExtAgentActionSpec{
        {Name: "create", Description: "Create category", HandlerFunc: fred.CreateCategory},
        {Name: "delete", Description: "Delete category", HandlerFunc: fred.DeleteNode},
        {Name: types.FredAddCategory, Description: "Add subcategory", HandlerFunc: fred.AddCategory},
    },
    Relations: []*nodlinAgent.ExtAgentRelationshipSpec{
        {
            OverRelation: types.FredDataSubCategory,
            ToType:       types.FredDataCategory,
            Label:        "has subcategory",
            InverseLabel: "parent category",
            Weight:       10,
        },
    },
}

Actions

Handlers have a single signature:

type ActionFunc func(nodlin *nodlinAgent.Nodlin) error
type EventFunc  func(nodlin *nodlinAgent.Nodlin) error

Return a non-nil error to fail the operation; nil reports success.

Required actions

ActionWhenTypical work
createNode is created or saved from the formDefaults, form, image, label/summary, StoreNode
deleteNode is deletedTear down side-state; often nl.Delete(ctx)

Optional lifecycle actions

Declare only what you handle:

ActionFired when
assignedAssignment created on the node
updatedAssignmentAssignment details changed
completeAssignmentAssignment completed
incompleteAssignmentAssignment re-opened
fyiUpdatedFYI created or updated
fyiAllAcknowledgedAll FYIs acknowledged
fileupdateFile attached/changed
duplicatedNode copied (e.g. import) β€” rebuild side-state

Custom action names are fine for form buttons (addCategory, chartSeries, …).

An action is delivered only if the type advertises it. Missing handlers are skipped (useful when an agent is offline for optional hooks).


Relationships and events

Relationships

&nodlinAgent.ExtAgentRelationshipSpec{
    OverRelation:    types.RelHasItem,
    ToType:          types.MyItem,
    Label:           "has item",
    InverseLabel:    "item of",       // display only β€” does not create reverse reactivity
    InverseRelation: types.RelItemOf, // only if the other side must react
    Weight:          10,
}

Rules that matter in practice:

  • A declared relation is a dependency: the owning type may re-evaluate when the related node changes. Do not add reactive edges β€œjust in case”.
  • InverseLabel is informational (menus / reverse display). It does not by itself cause reverse re-execution.
  • Set InverseRelation only when the other node must react. For true bidirectional pairs, both types should declare the inverse so a link from either side produces both edges.
  • One-way reaction is intentional when only one side depends on the other (e.g. query β†’ observation if the observation does not need query status).
  • When creating links in code, always pass a human userLabel matching the spec label β€” otherwise the canvas shows the raw relation FQN.

Events

&nodlinAgent.ExtAgentEventSpec{
    Name:         "*", // updated + linked + deleted
    FromType:     types.MyItem,
    OverRelation: types.RelHasItem,
    HandlerFunc:  p.OnItemChange,
}

Guidelines:

  • Default to "*" so link, unlink/delete, and data updates all recompute.
  • Use a named event when intermediate StoreNodes would thrash dependents; raise it explicitly with nl.SendEvent(ctx, "documentReady").
  • Prefer specific names for expensive external recomputes.

Prefer platform constructs before new types

Before inventing a domain type, check whether Nodlin already models the concept.

NeedPrefer
Todo / assignee / work itemAssignments on a node + lifecycle actions
Inform-only participantsFYIs + fyiUpdated / fyiAllAcknowledged
Task / question / option / decisionInternal workflow types
Notes / commentsExisting internal note/comment capabilities

Example β€” meeting attendees: do not store an attendees array in UDT. Participants are people on assignments (and informed people on FYIs). The meeting node reads ActionList / FYIList, renders names in label/summary/image, and registers lifecycle handlers so complete/ack updates refresh the display.

Assignments and FYIs

actions, err := nl.ActionList(ctx)       // or ActionListForNode
fyis, err := nl.FYIList(ctx)             // or FYIListForNode

The product UI already supports adding assignees, completion, calendar views, and FYI acknowledgement. Use those APIs and lifecycle hooks rather than reinventing todo lists in payload fields.

Internal workflow types (reuse)

Shared building blocks users already recognise (from the workflow agent), including:

FQNRole
agt_core_all_workflowAgent_taskTask / todo-style work
agt_core_all_workflowAgent_questionQuestion
agt_core_all_workflowAgent_optionOption
agt_core_all_workflowAgent_decisionDecision
agt_core_all_workflowAgent_checkpointCheckpoint
agt_core_all_workflowAgent_commentComment

When designing an external agent:

  1. Ask whether a workflow type already fits.
  2. If it almost fits, prefer extending the internal type over a parallel external twin.
  3. Link external domain nodes to workflow nodes when composition is clearer than duplication.
  4. Use ActionForAgent when creating or updating nodes owned by another agent.

Handler context: *nodlinAgent.Nodlin

Every action and event handler receives a Nodlin value: request header, expanded node context, and command helpers.

In-context vs out-of-context

ContextWhenAllowed writes
In-contextInside the handler before it returnsStoreNode, Link*, Action, Checkpoint, SendEvent, …
Out-of-contextGoroutine / worker after the handler returnsExecuteCommandSequence, ReportNodeActivity, SendUserNotification, SendRequest

When the handler returns, the framework ACKs success and the server closes the trace. Calling nl.StoreNode from a background goroutine after return fails with a context not found style error.

Returning without StoreNode is valid β€” the framework still ACKs. Unstored action payload fields are discarded.

Important methods

Request / identity

  • IsActionRequest, IsEventRequest, IsNew
  • RequestActionName, RequestEventName
  • GetUserGraphContext, MsgHeader, NewID

Read nodes

  • GetNodeWithUpdates β€” central node with action UDT overlay (usual create/save path)
  • GetCentralNode β€” node before updates (nil if new)
  • GetRelatedActiveNodes / GetRelatedNodes
  • GetNodeID, GetNodeAndGraphDetailInContext

Write

  • StoreNode, Delete / DeleteNode, Restore / RestoreNode
  • Link, LinkFrom, Unlink, UnlinkFrom
  • Checkpoint, Action, ActionForAgent, SendEvent

Collaboration

  • ActionList, ActionListForNode
  • FYIList, FYIListForNode

Agent-level (*agent.NodlinAgent)

  • ExecuteCommandSequence
  • ReportNodeActivity
  • SendUserNotification
  • Shutdown, GetAgentDetail

UDT helpers

  • udt.NewNodeJsonUDTFromType(v)
  • json.Unmarshal(node.UDT, &v)

Standard handler patterns

Create

func (p *Processor) CreateCategory(nl *nodlinAgent.Nodlin) error {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    updated, err := nl.GetNodeWithUpdates()
    if err != nil {
        return errors.Join(err, errors.New("load node"))
    }

    var data types.Category
    if err := json.Unmarshal(updated.UDT, &data); err != nil {
        return errors.Join(err, errors.New("decode udt"))
    }
    // defaults + domain logic β€” including recompute from related nodes

    updated.Label = util.PStr(data.Name)
    summary := buildSummary(data)
    updated.Summary = &summary
    updated.NodeSizeX = util.PFloat64(250)
    updated.NodeSizeY = util.PFloat64(150)
    updated.Image = /* PNG or SVG */
    form, err := createCategoryForm(&data)
    if err != nil {
        return err
    }
    updated.Schema = *form

    udtBytes, err := udt.NewNodeJsonUDTFromType(data)
    if err != nil {
        return err
    }
    updated.UDT = *udtBytes

    return nl.StoreNode(ctx, updated.ExtAgentNode)
}

Important: if event handlers derive fields from related nodes, create must apply the same derivation. Otherwise user edits look stale until some other event fires.

Delete

func (p *Processor) DeleteNode(nl *nodlinAgent.Nodlin) error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    return nl.Delete(ctx)
}

This ordered flow is extremely common for in-context child creation:

  1. create child (nl.Action … "create")
  2. Checkpoint so the child exists before linking
  3. link (Link / LinkFrom) with userLabel
  4. optional further checkpoint
newID := util.NewID()
if err := nl.Action(ctx, types.FredDataCategory, "", "create", newID, newUDT, nil); err != nil {
    return err
}
if err := nl.Checkpoint(ctx); err != nil {
    return err
}
return nl.LinkFrom(ctx,
    parentID, types.FredDataCategory,
    newID, types.FredDataCategory,
    types.FredDataSubCategory,
    "has subcategory", "parent category", 10,
)
Internally, Nodlin has a combined create-and-link path (NL_CLIENT_NODE_AND_LINK_ACTION). That convenience is not currently exposed on the external Go API. External agents must use explicit create β†’ checkpoint β†’ link (in-context) or the equivalent multi-checkpoint command sequence (out-of-context).

Long-running external work

Handlers should finish quickly (sub-second target after scheduling work). External HTTP or AI can take minutes and will trip action timeouts and NATS heartbeats if done inline.

Pattern (OpenAI-style):

  1. In the handler: validate, mark InProgress, build form/image, StoreNode, enqueue work, return.
  2. Capture before return: message header, user/graph context, node and graph ids.
  3. Background worker:
    • call the external API
    • ReportNodeActivity periodically so the UI pulses
    • notify the user on start/failure/success
    • apply results with ExecuteCommandSequence (new out-of-context stream)
  4. Never call nl.StoreNode from the background after the original handler returned.

Command sequences

seq, createCP := nodlinRequest.NewCommandRequestSequence()
correlationID := shortid.MustGenerate()

actionMsg := &nodlinCommands.ExtNodeActionMsg{
    NodeID: newID, NodeVersion: shortid.MustGenerate(),
    NodeType: types.MyItem, Action: "create", UDT: *itemUDT,
    GraphID: graphID, GraphDomain: domain,
}
actionMsg.WithCommandID(shortid.MustGenerate()).WithCorrelationID(correlationID)
actionMsg.WithMessageHeader(domainAgentId, extAgentId, remoteAgentId, hostname).
    AsUser(userID, userDomain).ForGraph(graphID, domain)
createCP.AddRequest(actionMsg)

linkCP := createCP.NextCheckpoint()
linkMsg := &nodlinCommands.ExtNodeLinkMsg{
    Operation:    nodlinAgent.NL_LINK_CREATE,
    FromNodeID:   parentID,
    FromNodeType: types.MyCategory,
    ToNodeID:     newID,
    ToNodeType:   types.MyItem,
    OverRelation: types.RelHasItem,
    UserLabel:    "has item",
    Weight:       10,
}
linkMsg.WithCommandID(shortid.MustGenerate()).WithCorrelationID(correlationID)
linkMsg.WithMessageHeader(domainAgentId, extAgentId, remoteAgentId, hostname).
    AsUser(userID, userDomain).ForGraph(graphID, domain)
linkCP.AddRequest(linkMsg)

_ = agent.ExecuteCommandSequence(ctx, *seq, /* max duration */ nil)

Checkpoint ordering: create nodes β†’ next checkpoint β†’ links β†’ optional status update. Stamp every command with header, user, graph, command id, and shared correlation id.

If two sequential handlers might start the same background job for one node, guard with a module-level set/mutex.


Forms (vueform)

External Go agents build the same vueform JSON schemas the web client understands. Concepts match the forms guide; the Go builder lives in github.com/johnha/nodlinGo/v2/vueform.

form := vueform.NewForm()
view := form.Schema.AddGroup("mainView")

header := view.AddGroup("_header", vueform.SchemaOpts{Align: vueform.String("right")})
col1 := header.AddColumn("_h1", 3)
col2 := header.AddColumn("_h2", 9)
col1.AddImage("_icon", string(images.ICON), vueform.ImageOpts{Width: vueform.String("48")})
col2.AddStatic("_title", "h3", vueform.StaticOpts{Content: util.PStr("Category")})

view.AddTextInput("name", vueform.SchemaOpts{
	Label: vueform.String("Name"),
	Info:  vueform.String("Short display name for this category"),
})
// expandable editor for long descriptions
// buttons with Action + Payload for custom actions

schemaBytes, err := form.JsonForm() // wraps as {"vueform": ...}
updated.Schema = *(*schema.NodeJsonSchema)(schemaBytes)

User / group select (AddUserGroupSelect)

Same control as Starlark addUserGroupSelect / client type user-group-select. Field value is a ContactList object:

{ Users: [{userid, domain:{domainName}}], Groups: [{groupName, group_domain:{domainName}}], Domains?: [{domainName}] }.

Defaults when opts are omitted: multi-select, users + groups, domains off; default value {Users:[], Groups:[]}.

allowDomains := false
singleOnly := false
allowUserOnly := false

view.AddUserGroupSelect(
	"assignees",
	vueform.UserGroupSelectOpts{
		SingleContactOnly: &singleOnly,
		AllowUserOnly:     &allowUserOnly,
		AllowDomains:      &allowDomains,
	},
	vueform.SchemaOpts{
		Label: vueform.String("Assignees"),
		Info:  vueform.String("Select users and/or groups responsible for this work"),
	},
)

// Single owner (users only)
trueVal := true
view.AddUserGroupSelect(
	"owner",
	vueform.UserGroupSelectOpts{SingleContactOnly: &trueVal, AllowUserOnly: &trueVal},
	vueform.SchemaOpts{Label: vueform.String("Owner")},
)
OptionMeaning
SingleContactOnlyOne contact vs multi
AllowUserOnlyHide groups
AllowDomainsInclude domains in the picker

On the next action, read the UDT field as ContactList (same JSON keys as Starlark). Platform user actions/assignments for scripts are created via the script runtime (createAction / createAssignment); Go external agents typically use command sequences / response-server messages for equivalent work. See the forms guide for Starlark end-to-end assign examples.

Rules:

  • Every editable field needs Info help (meaning, units, ranges).
  • Tabs that accept input need a unique save button id when multiple saves exist.
  • Action buttons: ButtonOpts{Action, Payload} map into UDT fields on the next action.
  • Prefer progressive disclosure (expand/add buttons) over dumping huge child lists as always-visible nodes.

Common builders: AddGroup, AddColumn, AddTextInput, AddTextArea, AddEditor, AddExpressionEditor, AddUserGroupSelect, AddButton, AddSlider, AddRadioGroup, AddSelect, AddCheckbox, AddStatic, AddImage, AddSpace, AddDivider, optional tabs via form.AddTab.


Node display and images

Users scan the canvas by label, icon, summary, and edge labels. Space is small β€” the node image should communicate status at a glance so users need not open the form.

Always set on store:

  • Label
  • Summary (markdown: description and key metrics; not only a repeat of the label)
  • NodeSizeX / NodeSizeY (common ~250Γ—150–350Γ—200)
  • Runtime Image
  • Type-level Icon and markdown Help

Prefer templated SVG for bespoke cards

ApproachWhen
SVG template + image.NewNodlinSVGImageStatus colours, multi-line text, designer-owned layout
PNG via nodlinImage text boxSimple bordered label + icon chrome
const cardSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="160">
  <rect width="100%" height="100%" fill="{$BG}" rx="12"/>
  <text x="16" y="40" font-size="18" fill="#fff">{$LABEL}</text>
  <text x="16" y="70" font-size="14" fill="#eee">{$STATUS}</text>
</svg>`

svg := strings.NewReplacer(
    "{$BG}", bg,
    "{$LABEL}", html.EscapeString(label),
    "{$STATUS}", status,
).Replace(cardSVG)
node.Image = image.NewNodlinSVGImage(svg)

Keep templates as complete SVG documents with placeholders β€” not ad-hoc concatenation of path fragments β€” so designers can round-trip through tools such as BoxySVG. Encode scannable signals (status colour, type mark, short label, counts).


Design checklist

Before coding types, answer:

  1. What decisions does the user make on the canvas vs in a form?
  2. What must recompute when something else changes? (Only those get reactive edges.)
  3. Can collaboration be assignments/FYIs instead of new nodes/fields?
  4. Can an internal workflow type cover this instead of a new type?
  5. Can two concepts be fields on one node without losing search/link value?
  6. Are edges labeled in language the user understands?
  7. Is fan-out bounded, or do we need expand-on-demand actions?
  8. If there is a cycle, will handlers converge at quiescence?

Implementation checklist

  • Agent name, version, ExecUser/ExecDomain set
  • Each type has icon, label, markdown Help, create, delete
  • Considered assignments/FYIs and internal workflow types before new types
  • Constants for all type/relation FQNs
  • Forms with field Info, header icon, unique save/action button ids
  • Labels, summaries, scannable images set on store
  • Event subscriptions match true dependency edges only
  • InverseRelation only where reverse reactivity is required
  • create β†’ checkpoint β†’ link (or command-sequence equivalent)
  • Long I/O offloaded; command sequences for out-of-context writes
  • Activity reporting + user notifications for multi-second jobs
  • Secrets from env/flags
  • Agent builds and registers cleanly in your environment

Common failure modes

SymptomLikely cause
Action timeoutExternal I/O awaited inside the handler
Context not found on storeBackground used nl.StoreNode after handler return
Edge shows FQNMissing UserLabel on link
Dependent never updatesWrong edge direction, missing event, or missing InverseRelation
Stale derived fields after savecreate did not recompute like the event handler
Agent dies under loadBlocking work without returning; heartbeat starvation
Registration failsMissing hostname / NATS / domain agent id, or invalid spec

Example agents

ExampleWhat to copy
FREDClean scaffold, REST expand-on-demand, forms with action payloads, simple images
OpenAIBackground workers, ReportNodeActivity, multi-node command sequences, named reactive events

Study those implementations alongside this guide when building a new agent.


Package map (quick)

Import pathRole
github.com/johnha/nodlinGo/v2/agentNewNodlinAgent, command sequences, activity, notifications
github.com/johnha/nodlinGo/v2/nodlinAgentSpecs + Nodlin handler context
github.com/johnha/nodlinGo/v2/nodlinTypesWire types, user/graph context
github.com/johnha/nodlinGo/v2/nodlinCommandsOut-of-context command messages
github.com/johnha/nodlinGo/v2/nodlinRequestCommand sequences and checkpoints
github.com/johnha/nodlinGo/v2/node/udtUDT encode/decode
github.com/johnha/nodlinGo/v2/vueformForm builder
github.com/johnha/nodlinGo/v2/imagePNG/SVG node images
github.com/johnha/nodlinGo/v2/utilIDs and pointer helpers
github.com/johnha/nodlinImage/...Text-box icon images (optional)

Summary

External Go agents let Nodlin reach the outside world without abandoning the reactive graph model. Register types, react with focused handlers, store scannable nodes, and keep long work out of the request path. Prefer platform assignments, FYIs, and workflow types before inventing parallel concepts β€” and treat every relation as a deliberate dependency, not decoration.

John Harrington
Authors
John Harrington
Founder
A technology leader and software architect with 20+ years in financial services and enterprise systems, founder of Nodlin Technologies LtdοΏΌ, building connected AI-driven operational intelligence platforms.