Project layout

Make a new folder for your plugin somewhere convenient. Inside it, you only need a single file called main.go. There’s no go.mod file to manage because your plugin imports packages from abyss itself, and the build command points Go at the abyss source tree.

A typical folder looks like this:

my-plugin/
└── main.go

Every plugin file starts with the same three things: a build tag, a package declaration, and an empty main function. The build tag (//go:build wasip1) tells Go “compile this for the WASM sandbox, not for my normal operating system.” The empty main looks pointless, but the plugin machinery requires it to exist even though it does nothing — the real work happens in an init function instead.

//go:build wasip1

package main

func main() {}

Getting Started

The raw interface is powerful but verbose: you have to decode every message yourself, even the ones you don’t care about. The typed router flips that around. You write a plain old struct and only add methods for the message types you’re interested in. Abyss does the decoding, calls only the methods that match, and silently passes through everything else.

We’ll write a plugin that looks at each prompt the user sends and refuses to pass it along if it contains a banned word.

Step 1 — Imports and the struct

Start the file the same way, but this time we also pull in the abyss package (for the router), the acp-go-sdk package (which holds the typed message structs), and context, which our Initialize method will need:

//go:build wasip1

package main

import (
	"context"
	"regexp"
	"strings"

	"github.com/SethCurry/abyss/pkg/abyss"
	"github.com/SethCurry/abyss/pkg/protobyss"
	"github.com/coder/acp-go-sdk"
)

func main() {}

// PromptFilter holds any state our plugin needs. Ours keeps a list of
// banned regular expressions to test prompts against.
type PromptFilter struct {
	bannedRegexes []*regexp.Regexp
}

Step 2 — Say hello with Initialize

Just like the raw interface, the typed router starts with the handshake: every plugin needs an Initialize method, and abyss.NewACPPluginRouter won’t accept a struct that’s missing one — the compiler will tell you so in no uncertain terms. Add this to the bottom of your file:

// Initialize is called once when abyss loads the plugin, before
// any messages are handled.
func (p *PromptFilter) Initialize(
	ctx context.Context,
	req *protobyss.ACPPluginInitializeRequest,
) (*protobyss.ACPPluginInitializeResponse, error) {
	return &protobyss.ACPPluginInitializeResponse{
		Name: "prompt_filter",
	}, nil
}

The request carries the same OnHost flag and Config bytes we met earlier; our filter has no use for either, so it simply replies with its name, "prompt_filter". When abyss loads the plugin and comes knocking, the router passes the handshake straight through to this method — no extra wiring on your part.

Step 3 — Register it in init

As before, we create an instance and hand it to abyss. The difference is that we wrap our struct in abyss.NewACPPluginRouter(...) first. That wrapper is what scans our struct for On... methods and wires them up to the right message types, and it carries our Initialize method along too, so the handshake “just works.”

func init() {
	bannedStrings := []string{".*SECRET.*"}
	regexes := make([]*regexp.Regexp, len(bannedStrings))
	for i, s := range bannedStrings {
		regexes[i] = regexp.MustCompile(s)
	}

	plug := &PromptFilter{bannedRegexes: regexes}
	protobyss.RegisterACPPlugin(abyss.NewACPPluginRouter(plug))
}

Here we’ve hardcoded the banned patterns as ".*SECRET.*", but you could read them from the message itself, load them another way, or swap the regexes for a plain string check — it’s all just Go.

Step 4 — Implement the methods you care about

We only care about prompts, so the only On... method we implement is OnPromptRequest. The router figures out the rest on its own.

// OnPromptRequest is called whenever the user sends a prompt to the agent.
func (p *PromptFilter) OnPromptRequest(
	req acp.PromptRequest,
) ([]*protobyss.ACPContainer, error) {
	// Gather all the text from the prompt into one string.
	allText := strings.Builder{}
	for _, v := range req.Prompt {
		if v.Text != nil {
			allText.WriteString(v.Text.Text)
		}
	}

	// If any banned pattern matches, refuse the prompt.
	for _, re := range p.bannedRegexes {
		if re.Match([]byte(allText.String())) {
			// Tell the agent we're refusing to continue.
			resp := acp.PromptResponse{StopReason: acp.StopReasonRefusal}
			respContainer, err := abyss.ACPContainer(resp)
			if err != nil {
				break
			}

			// Send the user a message explaining what happened.
			notice := acp.SessionNotification{
				SessionId: req.SessionId,
				Update: acp.SessionUpdate{
					AgentMessageChunk: &acp.SessionUpdateAgentMessageChunk{
						Content: acp.TextBlock(
							"\n\n\nNuh uh, not under my roof!\n" +
								"You have violated a security filter.",
						),
					},
				},
			}
			noticeContainer, err := abyss.ACPContainer(notice)
			if err != nil {
				break
			}

			// Returning these two replaces the original prompt
			// entirely — it never reaches the agent.
			return []*protobyss.ACPContainer{respContainer, noticeContainer}
		}
	}

	// No match: pass the original prompt through untouched.
	return abyss.ACPContainers(req)
}

A few things to notice:

  • The argument (req acp.PromptRequest) arrives already decoded into a proper Go struct, courtesy of the router. You never touch raw bytes.
  • The return type is the same []*protobyss.ACPContainer as before. The helper abyss.ACPContainers(req) is the easy way to turn a typed struct back into that slice when you just want to pass it through. Use abyss.ACPContainer(thing) (singular) when you’re building a single new message to return.
  • Returning two containers, as we do in the refusal branch, replaces the original prompt with both of them. The original prompt is gone — only what you return continues down the pipe.
  • Unlike the raw interface, the router fills in MessageId and ResponseFor for you on new messages you synthesize, so you don’t have to set them yourself.

Step 5 — Which methods can I implement?

NewACPPluginRouter looks at your struct and connects any of a long list of On... methods it finds. (Initialize isn’t one of these — it isn’t a message handler, it’s the required handshake from Step 2.) A handful of the common ones:

MethodFires when…
OnPromptRequestthe user sends a new prompt.
OnPromptResponsethe agent finishes responding to a prompt.
OnSessionNotificationthe agent streams a chunk of a reply.
OnNewSessionRequesta new session is being created.
OnWriteTextFileRequestthe agent asks to write a file.
OnReadTextFileRequestthe agent asks to read a file.
OnCreateTerminalRequestthe agent asks to start a shell command.
OnRequestPermissionRequestthe agent asks permission to do something.

The full list lives in pkg/abyss/plugin_router_builder.go in the abyss source tree — one interface per ACP message type. You only ever implement the ones you care about; the rest are silently ignored.

The handler signature is always the same shape:

func (p *MyPlugin) OnSomething(req acp.Something) ([]*protobyss.ACPContainer, error)

Step 6 — Build it

Same command as before, from inside the plugin’s folder:

GOOS=wasip1 GOARCH=wasm go build -o plugin.wasm -buildmode=c-shared main.go

Add it to your config under plugins.client, restart abyss, and try sending a prompt that contains the word SECRET. Instead of reaching the agent, you’ll see your refusal message come back.

Logging From Inside a Plugin

A plugin runs in a sandbox, so the usual Go logging habits (fmt.Println, log.Printf) work, but their output goes to the plugin’s own stdout rather than into abyss’s log stream. If you want your messages to show up alongside everything else abyss logs — the best place to look when something goes wrong — use the host logging functions abyss provides.

Grab a logger once with protobyss.NewLogging(), then call Debug, Info, Warn, or Error on it. Each takes a protobyss.LogMessage with two fields: Message (the text) and Fields (an optional map of string keys to string values, which show up as structured fields in the log line). One-time setup like this also fits nicely inside your Initialize method, since it runs before the first message arrives — the example below does it in init instead, which works just as well.

Here’s the PromptFilter from above, updated to stash a logger on its struct and announce itself when it loads. The "context" import we added back in Step 1 covers the context.Background() call:

type PromptFilter struct {
	bannedRegexes []*regexp.Regexp
	log           protobyss.Logging
}

func init() {
	log := protobyss.NewLogging()

	log.Info(context.Background(), &protobyss.LogMessage{
		Message: "prompt filter loaded",
		Fields:  map[string]string{"version": "1.0"},
	})

	plug := &PromptFilter{
		bannedRegexes: []*regexp.Regexp{regexp.MustCompile(".*SECRET.*")},
		log:           log,
	}
	protobyss.RegisterACPPlugin(abyss.NewACPPluginRouter(plug))
}

Every log line your plugin emits is tagged with the plugin’s path, so in the abyss logs you can tell at a glance which plugin said what.

Complete Examples in the Repository

Abyss ships two ready-to-run example plugins in the example/plugins directory of the source tree. They’re the same ideas we built above, polished and commented:

  • global_logger — the raw-interface logger, for when you want to see every message regardless of type.
  • prompt_filter — the typed-router prompt filter, for when you only care about a few message types.

Both already contain a built plugin.wasm and a README.md explaining the design choices they make. Copying one of these folders is the fastest way to start your own plugin — rename the struct, edit the handler methods, rebuild, and you’re done.

A Few Things to Keep in Mind

  • Keep the //go:build wasip1 line at the very top of main.go. Without it, the build produces a normal program instead of a WASM module and abyss won’t be able to load it.
  • Keep the empty main function. The plugin loader needs the file to be a main package even though main itself does nothing; all the real startup happens in init.
  • Every plugin must implement Initialize. Abyss calls it once, as soon as it loads your plugin, and your plugin answers with its name. Both flavors check for the method at build time, so a plugin without it won’t compile — let alone load.
  • Plugins run on the client side, before messages enter the container. That means they can see and shape what your agent is allowed to do, but they can’t see anything happening inside the container that doesn’t come back out as an ACP message.
  • Plugins run in order. When you list several, each one’s output becomes the next one’s input. Put the plugin you want to have the first or last word in the matching position.
  • Plugins are sandboxed. They can’t read your filesystem, make network calls, or spawn processes unless abyss grants them the ability. Logging is the one host ability exposed today.