This tutorial walks through creating a small Emerald plugin from scratch.
You will build one action node that:
- appears in the normal
Actionspalette - accepts a URL and an optional bearer token
- performs an HTTP request
- exposes
successanderroroutput pins
At the end, Emerald will load the plugin as a normal node with custom config fields and branching outputs.
Emerald now also supports plugin-defined trigger nodes. This tutorial stays action-focused for the first build, then points you to a trigger example at the end.
You need:
- a working Emerald checkout
- Go installed
- a place for local plugins, usually
.agents/plugins
Emerald discovers plugins from:
EMERALD_PLUGINS_DIR, if set- the nearest
.agents/plugins - a fallback
.agents/pluginsunder the current working directory
For this tutorial, we will create a self-contained bundle under:
.agents/plugins/hello-http
Create this layout:
.agents/
plugins/
hello-http/
plugin.json
go.mod
main.go
bin/
If you prefer to keep plugin source somewhere else, that also works. The only requirement is that Emerald can find plugin.json, and that the manifest points at a valid executable.
Create plugin.json with:
{
"id": "hello-http",
"name": "Hello HTTP",
"version": "0.1.0",
"description": "Example plugin with one branching HTTP action node.",
"executable": "./bin/hello-http.exe"
}Notes:
- On Windows, using
.exeis correct. - On macOS or Linux, the executable is usually
./bin/hello-httpinstead. - The
idbecomes part of the node type, so keep it stable once people start using the plugin.
Emerald will expose nodes from this plugin under:
action:plugin/hello-http/<node-id>
trigger:plugin/hello-http/<node-id>
tool:plugin/hello-http/<node-id>
Inside the plugin directory, initialize a Go module:
go mod init example.com/hello-http
go get github.com/FlameInTheDark/emerald@latestIf you are developing the plugin against a local Emerald checkout and want to pin to your current workspace, you can add a replace directive:
module example.com/hello-http
go 1.26.1
require github.com/FlameInTheDark/emerald v0.0.0
replace github.com/FlameInTheDark/emerald => H:/Projects/Go/src/github.com/FlameInTheDark/emeraldThat keeps your plugin building against the same local SDK code Emerald is using.
Create main.go with this full example:
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/FlameInTheDark/emerald/pkg/pluginapi"
"github.com/FlameInTheDark/emerald/pkg/pluginsdk"
)
type fetchConfig struct {
URL string `json:"url"`
Method string `json:"method"`
BearerToken string `json:"bearerToken"`
}
type fetchAction struct{}
func (a *fetchAction) ValidateConfig(_ context.Context, config json.RawMessage) error {
cfg, err := decodeFetchConfig(config)
if err != nil {
return err
}
if strings.TrimSpace(cfg.URL) == "" {
return fmt.Errorf("url is required")
}
return nil
}
func (a *fetchAction) Execute(ctx context.Context, config json.RawMessage, input map[string]any) (any, error) {
cfg, err := decodeFetchConfig(config)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, cfg.Method, cfg.URL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if strings.TrimSpace(cfg.BearerToken) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.BearerToken))
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return map[string]any{
"status_code": resp.StatusCode,
"status": resp.Status,
"body": decodeJSONOrString(bodyBytes),
"source": "hello-http",
"matches": map[string]bool{
"success": resp.StatusCode < http.StatusBadRequest,
"error": resp.StatusCode >= http.StatusBadRequest,
},
}, nil
}
func main() {
bundle := &pluginapi.Bundle{
Info: pluginapi.PluginInfo{
ID: "hello-http",
Name: "Hello HTTP",
Version: "0.1.0",
APIVersion: pluginapi.APIVersion,
Nodes: []pluginapi.NodeSpec{
{
ID: "fetch_json",
Kind: pluginapi.NodeKindAction,
Label: "Fetch JSON",
Description: "Perform an HTTP request and branch on success or error.",
Icon: "globe",
Color: "#14b8a6",
MenuPath: []string{"Hello HTTP", "Requests"},
DefaultConfig: map[string]any{
"url": "https://api.github.com",
"method": "GET",
"bearerToken": "",
},
Fields: []pluginapi.FieldSpec{
{
Name: "url",
Label: "URL",
Type: pluginapi.FieldTypeString,
Required: true,
Placeholder: "https://api.example.com/data",
TemplateSupported: true,
},
{
Name: "method",
Label: "Method",
Type: pluginapi.FieldTypeSelect,
Required: true,
Options: []pluginapi.FieldOption{
{Value: "GET", Label: "GET"},
{Value: "POST", Label: "POST"},
},
DefaultStringValue: "GET",
},
{
Name: "bearerToken",
Label: "Bearer Token",
Type: pluginapi.FieldTypeString,
Placeholder: "{{secret.api_token}}",
TemplateSupported: true,
},
},
Outputs: []pluginapi.OutputHandle{
{ID: "success", Label: "Success", Color: "#22c55e"},
{ID: "error", Label: "Error", Color: "#ef4444"},
},
OutputHints: []pluginapi.OutputHint{
{Expression: "input.status_code", Label: "HTTP status code"},
{Expression: "input.status", Label: "HTTP status text"},
{Expression: "input.body", Label: "Response body"},
},
},
},
},
Actions: map[string]pluginapi.ActionNode{
"fetch_json": &fetchAction{},
},
}
pluginsdk.Serve(bundle)
}
func decodeFetchConfig(raw json.RawMessage) (fetchConfig, error) {
cfg := fetchConfig{Method: http.MethodGet}
if len(raw) == 0 {
return cfg, nil
}
if err := json.Unmarshal(raw, &cfg); err != nil {
return fetchConfig{}, fmt.Errorf("decode config: %w", err)
}
if strings.TrimSpace(cfg.Method) == "" {
cfg.Method = http.MethodGet
}
return cfg, nil
}
func decodeJSONOrString(raw []byte) any {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return ""
}
var decoded any
if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil {
return decoded
}
return trimmed
}The full example is small, but a few pieces matter a lot.
Bundle is the easiest way to expose a plugin:
Infodescribes the plugin and its nodesActionsmaps action node IDs to implementationsToolsmaps tool node IDs to implementations
If Info.Nodes contains an action with ID fetch_json, then Actions must include fetch_json too.
NodeSpec controls how Emerald sees your node:
IDis the stable node ID inside your pluginKindisactionortoolLabel,Description,Icon, andColordrive the editor UIMenuPathcontrols where the node appears in the palette and context menuDefaultConfigbecomes the initial config for newly created nodesFieldsdrives the generic config formOutputsadds output pins on action nodesOutputHintsimproves autocomplete in downstream templated fields
ValidateConfig receives the raw saved config, not rendered template values.
That means it should validate:
- required keys
- JSON shape
- obviously invalid static values
It should not assume that {{input.foo}}, {{secret.api_token}}, or {{$('node-id').field}} is already resolved.
Execute receives config after Emerald renders templates. If the user enters:
{{secret.api_token}}
in the bearerToken field, your plugin receives the resolved string value, not the template expression.
The input parameter contains the current payload flowing into the node. That is the same runtime object users access in templates as input.
Template-enabled fields can also reference a specific earlier node output with syntax like {{$('action-http-1').response.status_code}} once that node has already executed in the current run.
MenuPath is optional, but it is the easiest way to keep larger plugins organized.
Examples:
[]string{"GitHub"}gives youActions -> GitHub -> Fetch JSON[]string{"GitHub", "Issues"}gives youActions -> GitHub -> Issues -> Fetch JSON[]string{}keeps the node at the category root
If you omit MenuPath for an action or tool node, Emerald places it under General.
Because this node declares custom outputs, the result must include:
{
"matches": {
"success": true,
"error": false
}
}Every declared output handle must appear in matches, and every value must be a boolean.
From the plugin directory:
go build -o .\bin\hello-http.exe .On macOS or Linux:
go build -o ./bin/hello-http .After this step, your bundle should look like:
.agents/
plugins/
hello-http/
plugin.json
go.mod
main.go
bin/
hello-http.exe
Emerald loads plugins when it starts, so restart the server after adding or rebuilding the plugin.
Once Emerald is back up:
- open the editor
- open the
Actionspalette - search for
Fetch JSON
You should see your node with the configured globe icon and two output pins: Success and Error.
Create a simple test pipeline:
- Add your
Fetch JSONnode. - Set
URLtohttps://api.github.com. - Leave
MethodasGET. - Optionally set
Bearer Tokento{{secret.api_token}}. - Connect the
Successoutput to a logging, prompt, or message node. - Connect the
Erroroutput to a different branch.
Downstream nodes can reference output values like:
{{input.status_code}}
{{input.status}}
{{input.body}}
Or they can reach this plugin node by ID from later template-enabled fields:
{{$('action-plugin-hello-http-fetch').status_code}}
Because the action returns a normal JSON object, plugin nodes behave like built-in nodes during templating.
If your plugin needs credentials:
- Open
Settings. - Open
Security -> Secretsand create a secret namedapi_token. - Put
{{secret.api_token}}into a template-enabled field.
At runtime, Emerald resolves the secret before calling your action. The secret value is not returned by normal secret list APIs and is not meant to be stored in ordinary execution context snapshots.
Once the action node works, you can add a tool node to the same plugin.
Tool nodes are meant to be connected to an llm:agent node. They implement:
type ToolNode interface {
ValidateConfig(ctx context.Context, config json.RawMessage) error
ToolDefinition(ctx context.Context, meta pluginapi.ToolNodeMetadata, config json.RawMessage) (*pluginapi.ToolDefinition, error)
ExecuteTool(ctx context.Context, config json.RawMessage, args json.RawMessage, input map[string]any) (any, error)
}The usual pattern is:
- Add a new
NodeSpecwithKind: pluginapi.NodeKindTool - Register a
ToolNodeimplementation inbundle.Tools - Build a JSON-schema-style tool definition in
ToolDefinition - Parse model arguments in
ExecuteTool - Return a JSON-compatible object
Small example:
type echoTool struct{}
func (t *echoTool) ValidateConfig(_ context.Context, config json.RawMessage) error {
return nil
}
func (t *echoTool) ToolDefinition(_ context.Context, meta pluginapi.ToolNodeMetadata, _ json.RawMessage) (*pluginapi.ToolDefinition, error) {
return &pluginapi.ToolDefinition{
Type: "function",
Function: pluginapi.ToolSpec{
Name: "echo_text",
Description: "Echo text back to the agent.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
},
},
"required": []string{"text"},
},
},
}, nil
}
func (t *echoTool) ExecuteTool(_ context.Context, _ json.RawMessage, args json.RawMessage, _ map[string]any) (any, error) {
var payload struct {
Text string `json:"text"`
}
if err := json.Unmarshal(args, &payload); err != nil {
return nil, err
}
return map[string]any{"echo": payload.Text}, nil
}Then register it:
bundle.Info.Nodes = append(bundle.Info.Nodes, pluginapi.NodeSpec{
ID: "echo_tool",
Kind: pluginapi.NodeKindTool,
Label: "Echo Tool",
Description: "Simple example tool node.",
Icon: "wrench",
Color: "#38bdf8",
MenuPath: []string{"Hello HTTP", "Agent Tools"},
})
bundle.Tools["echo_tool"] = &echoTool{}Tool nodes work, but one runtime detail matters:
ToolDefinitionis built before normal pipeline input is availableExecuteToolruns later with the real pipeline payload
So for v1:
- keep tool-definition config static when possible
- do not depend on
{{input.*}}while building the tool definition - do not depend on
{{$('node-id').*}}while building the tool definition unless that dependency is guaranteed to be available beforeToolDefinition - put dynamic behavior in
ExecuteTool
If you need a full working example that includes both an action node and a tool node, use:
examples/plugins/sample-request-kit
If the plugin does not load:
- verify
plugin.jsonis in the plugin root tree - verify the
executablepath is correct - verify the binary actually exists
- restart Emerald after rebuilding
If the plugin loads but the node is unavailable:
- check whether
PluginInfo.IDmatches the manifestid - check whether
APIVersionmatchespluginapi.APIVersion - check for duplicate node IDs
If the node appears but only one output pin shows up:
- confirm the node declares
OutputsinNodeSpec - confirm Emerald is loading the latest rebuilt binary
- confirm the editor is seeing the live plugin node definition, not an older stale node instance
Trigger plugins work a little differently from action and tool nodes:
- the node itself only validates config
- Emerald opens one long-lived trigger runtime stream per plugin
- Emerald sends full active subscription snapshots to the plugin
- the plugin emits
TriggerEventpayloads back to Emerald - Emerald starts the exact subscribed root node and exposes that payload downstream
The easiest reference is the sample trigger plugin in this repository:
examples/plugins/sample-trigger-kit
It shows:
Kind: pluginapi.NodeKindTriggerbundle.Triggersbundle.TriggerRuntimeProvider- a runtime that rebuilds its watchers from full snapshots
- emitted payload fields such as
message,sequence, andfired_at
After this tutorial, the most useful follow-ups are:
- read the plugin reference for field types, manifests, and runtime behavior
- inspect the full sample plugin at
examples/plugins/sample-request-kit - inspect the trigger sample at
examples/plugins/sample-trigger-kit - add a second action node or a real tool node once the first action node is working end to end