JS Extensions (Plugins)
Goa supports JavaScript plugins via the Goja runtime — a pure-Go JavaScript engine. Plugins run inside the Goa process and can register tools, commands, event observers, lifecycle hooks, UI elements (status-bar segments, panes, modals), hotkeys, and more.
This document is the complete plugin reference. The provider-quota plugin is the canonical reference implementation — read its source alongside this guide to understand every pattern.
Plugin Manifest
A plugin is a directory with:
my-plugin/
├── plugin.yaml # Manifest (required)
├── plugin.js # Entry point (default: "plugin.js")
├── lib/ # Modules (CommonJS)
├── fetchers/ # Sub-modules (any structure)
└── README.md # Optional documentation
Manifest fields
id: my-plugin # Required — unique plugin identifier
name: My Plugin # Required — human-readable name
version: 1.0.0 # Optional — semver
description: >- # Optional — what this plugin does
Provides custom tools for project management
entry: plugin.js # Optional — default: "plugin.js"
goa_min_version: 0.1.0 # Optional — minimum Goa version
skills_dir: skills # Optional — relative path to skills loaded on enable
permissions: # Optional — declare what the plugin needs
- provider-keys # "provider-keys" → expose API keys in goa.config()
hooks: # Optional — agent hooks this plugin registers
- point: tool-call:pre
mode: intercept
description: Redact AWS keys from tool arguments
| Field | Required | Description |
|---|---|---|
id |
Yes | Unique slug used in paths, lockfile, and /plugin commands |
name |
Yes | Human-readable display name |
version |
No | Semver string for update tracking |
description |
No | One-line description shown in /plugin:list |
entry |
No | Entry-point JS file; defaults to plugin.js |
goa_min_version |
No | Minimum Goa version required (not yet enforced) |
skills_dir |
No | Relative path to a directory of SKILL.md files loaded when enabled |
permissions |
No | List of permission strings: "provider-keys" (unmasks API keys in goa.config()), "oauth-token" (OAuth token access via goa.oauthToken()), "account-write", "ui-confirm". Unknown permission names are rejected at load. |
hooks |
No | Agent hook declarations — see Agent Hooks. Each entry needs point + mode; description is recommended and shown on the approval card. Unknown points/modes or duplicate declarations are rejected at load. |
Plugin storage
Each plugin gets its own persistent storage file at
~/.goa/plugins/<id>/storage.json (mode 0600 — it may hold credentials).
Access is via the goa.storage API — see Extended Bridges.
Plugin API Reference
The Goja runtime provides a goa global object with these methods. All are
synchronous from the JS perspective; bridges (http.fetch, storage) block
the calling JS code but not the TUI.
goa.config()
Returns the Goa configuration as a JavaScript object. The config is live —
calling goa.config() returns the current state after provider/model switches.
var cfg = goa.config();
// {
// activeProvider: "local",
// activeModel: "llama-3.2-1b-instruct",
// providers: {
// "local": { id:"local", name:"Local LLM", apiKey:"", baseUrl:"...", provider:"lm-studio" },
// "openai": { id:"openai", name:"OpenAI", apiKey:"sk-...", baseUrl:"https://api.openai.com" },
// ...
// }
// }
API keys are included only when the plugin declares the provider-keys
permission in its manifest. Otherwise the apiKey field is empty.
goa.logger()
Returns a logger with .info(), .warn(), .error(), .debug() methods.
var log = goa.logger();
log.info("Plugin loaded successfully");
log.error("Something went wrong: " + errorMessage);
goa.registerTool({name, description, execute})
Registers a new agent tool. The tool appears in the LLM's tool set.
| Parameter | Type | Description |
|---|---|---|
name |
string |
Tool name for LLM invocation |
description |
string |
LLM-facing description of when to use it |
execute |
function(params) |
Receives {paramName: value} and returns string or object |
goa.registerTool({
name: "current_time",
description: "Get the current date and time in ISO 8601 format",
execute: function(params) {
return new Date().toISOString();
}
});
The execute function receives a single object with named fields (matching the
LLM's JSON tool-call arguments). Return a string or object (serialized to JSON).
goa.registerCommand({name, aliases, shortHelp, longHelp, run})
Registers a slash command available as /<name>.
| Parameter | Type | Description |
|---|---|---|
name |
string |
Command name (e.g., quota) |
aliases |
string[] |
Optional alternative names |
shortHelp |
string |
One-line summary for /help listings |
longHelp |
string |
Full usage documentation for /help <cmd>?? |
run |
function(args) |
Receives string[] arguments, returns result string |
goa.registerCommand({
name: "hello",
shortHelp: "Say hello",
longHelp: "Usage: /hello [name]\n\nGreets the user by name.",
run: function(args) {
return args.length > 0 ? "Hello, " + args[0] + "!" : "Hello, World!";
}
});
The run function returns a string that Goa writes into the chat viewport.
goa.registerCompletion(name, fn)
Provides argument completions for a command: while the user types
/<name>:<prefix> in the input line, Goa calls fn(prefix) and offers the
returned candidates in the picker. The API is feature-detected — the goa
object only carries registerCompletion when the host supports it:
if (typeof goa.registerCompletion === "function") {
goa.registerCompletion("hello", function(prefix) {
return [
{ value: "loud", description: "Shout the greeting" },
{ value: "formal", description: "Use full titles" }
];
});
}
Contract (mirrors how built-in commands like /plugin complete):
| Aspect | Rule |
|---|---|
Value |
Bare segment, no leading colon and no command prefix — the engine prepends "/<name>:" |
Description |
One-line text shown next to the candidate |
prefix argument |
Everything typed after /<name>:: "", "re", or a nested parent path plus colon ("login:") |
| Nested levels | Return children for known parents ("login:" → provider ids); unknown parents return [] |
| Threading | fn runs under the plugin VM lock on the completer goroutine; it may read plugin state but must not block |
A throwing or malformed completer degrades to "no candidates" — it never breaks typing.
goa.registerObserver(callback(eventName, payload))
Subscribes to events from Goa's event bus. The callback receives every event;
filter by eventName in your handler.
goa.registerObserver(function(eventName, payload) {
if (eventName === "mode.changed") {
goa.logger().info("Mode changed from " + payload.from + " to " + payload.to);
}
});
See Events for the full event list.
goa.registerLifecycle(hook, callback(hook, payload))
Registers a lifecycle callback — invoked at key moments in the Goa runtime. The
hook parameter is one of the lifecycle hook names below. The callback
receives (hookName, payload) where payload is a context map.
Available hooks:
| Hook | When triggered | Payload |
|---|---|---|
"start" |
After all plugins are loaded and the runtime starts | {} |
"shutdown" |
During graceful shutdown | {} |
"tool_call" |
Before a tool is executed | {name, params} |
"tool_done" |
After a tool completes | {name, result} |
"mode_enter" |
When the autonomy mode changes | {from, to} |
goa.registerLifecycle("start", function(hook, payload) {
goa.logger().info("Runtime started — initializing plugin state");
});
goa.registerLifecycle("tool_done", function(hook, payload) {
if (payload.name === "write") {
goa.logger().info("File written: " + payload.params.path);
}
});
goa.registerLifecycle("shutdown", function(hook, payload) {
goa.logger().info("Plugin shutting down, saving state…");
// Persist any runtime state before Goa exits
});
goa.registerHook({name, point, mode, handler})
Registers an agent hook — a synchronous callback at a defined point in the
agent loop. Hooks are the most powerful plugin integration: intercept hooks
can rewrite or block tool calls and messages before the agent acts on them.
Requirements (enforced):
- The hook must be declared in
plugin.yamlunderhooks:with matchingpoint+mode. Undeclared registrations are rejected outright, regardless of grants. - The user must have approved it at the install-time review card. The
approval is stored in
~/.goa/plugins/grants.json(0600) keyed by plugin, version, and a fingerprint of the declared hook list. - In headless runs (no TUI), external-plugin hooks are disabled entirely
unless
GOA_PLUGIN_HOOKS_APPROVED=1is set — see Headless mode.
Rejected registrations surface as a warning on goa.output and in the log;
the rest of the plugin keeps loading.
| Point | Fires | Modes | Handler payload |
|---|---|---|---|
tool-call:pre |
Before each tool executes | intercept, notify |
{name, params} |
tool-call:post |
After each tool completes | intercept, notify |
{name, params, result} |
message:pre-send |
Before a user message reaches the LLM | intercept, notify |
{text} |
reply:pre / reply:delta |
Around assistant reply streaming | intercept, notify |
{...} |
llm:error |
On provider/stream errors | notify |
{error} |
An intercept handler returns {action: "allow"} (or undefined) to pass,
or {action: "deny", reason}, or {action: "modify", ...} to rewrite the
payload. A notify handler observes only; its return value is ignored.
goa.registerHook({
name: "redact-aws-keys",
point: "tool-call:pre",
mode: "intercept",
handler: function(payload) {
const s = JSON.stringify(payload.params);
if (!s.includes("AKIA")) return undefined;
goa.logger().warn("redacted AWS key in tool args: " + payload.name);
return { action: "modify", params: JSON.parse(s.replace(/AKIA[A-Z0-9]{16}/g, "[REDACTED]")) };
}
});
Hook grants & re-approval
The grant records which (point, mode) pairs you approved. When a plugin
update changes its declared hook list (adds/removes/toggles a hook) or
bumps its version, the grant goes stale: every hook registration is
rejected until you re-review. Goa shows the review card automatically after
reload for stale plugins, and /plugin:enable:<id> re-runs the review any
time. Rejecting leaves no grant, so all of that plugin's hooks stay off while
its tools/commands keep working.
Bundled plugins (shipped with Goa, e.g. provider-quota) are pre-approved and skip the review card.
Headless mode
Without a TUI there is no way to show the approval card, so external plugins' hooks fail closed: registrations are rejected with a loud log line. To run a delegated/headless session that still uses reviewed hooks, set:
GOA_PLUGIN_HOOKS_APPROVED=1
This lifts only the headless gate — manifest declaration checks and stored grants are still enforced.
goa.callTool(name, params)
Calls any registered tool from JavaScript. Returns the tool's output.
var result = goa.callTool("read", { path: "src/main.go" });
goa.logger().info("File content: " + result);
goa.sessionUsage()
Returns cumulative session token statistics:
var u = goa.sessionUsage();
// { input: 142300, output: 85500, turns: 12, toolCalls: 8 }
This is the same data the local (inferred) quota fetcher uses. Useful for plugins that want to track agent resource consumption.
goa.segmentColor(name)
Returns the active theme's hex color string for a semantic color name, or ""
when coloring is unavailable. Designed for status-bar segments that want
theme-aware coloring without emitting raw ANSI codes.
| Name | Semantic meaning | Typical theme color |
|---|---|---|
"ok" |
All within budget | Green |
"warn" |
Approaching limit | Orange/yellow |
"critical" |
Over budget or error | Red |
"pending" |
Still loading data | Neutral/gray |
var hex = goa.segmentColor("warn");
// → "#ff8800" or similar (depends on active theme)
The main use is building multi-colored segment strings — the provider-quota plugin uses this to color each quota window independently:
function colorizedSegment(entry) {
var parts = [];
for (var i = 0; i < entry.limits.length && parts.length < 2; i++) {
var hex = goa.segmentColor(partColor);
parts.push(hex ? ansiWrap(hex, text) : text);
}
return "[" + parts.join("|") + "]";
}
Events
Plugins observe these events via goa.registerObserver. The wildcard
observer receives all events:
goa.registerObserver(function(event, payload) {
// event is one of the strings below
// payload is the event-specific object
});
| Event | Payload | Description |
|---|---|---|
mode.changed |
{ from, to } |
Autonomy mode changed |
skill.changed |
{ name, type } |
Active skill changed |
tool.call |
{ name, params } |
A tool was invoked |
tool.result |
{ name, result } |
A tool returned a result |
session.start |
{ timestamp } |
A new agent session started |
session.end |
{ timestamp, turns } |
An agent session ended |
pipeline.stage |
{ pipeline, stage, status } |
Pipeline stage changed |
rate_limit_exceeded |
{ provider, model, attempt, retry_after_ms, classified, will_retry } |
Provider rate limit hit (forwarded from the agent loop once per retry attempt, and once more with will_retry: false when giving up) |
Extended Bridges
These bridges are available through the goa global when enabled.
goa.http.fetch(url, opts)
Performs an HTTP request on a background goroutine. The JS call blocks until
the response is ready, but only the calling JS flow blocks — the TUI stays
responsive. Requests are HTTPS-only, except loopback hosts (localhost,
127.0.0.1) for local model servers.
| Option | Type | Default | Description |
|---|---|---|---|
method |
string |
"GET" |
HTTP method |
headers |
object |
— | Request headers |
body |
string|object |
— | Payload; objects are JSON-encoded |
timeoutMs |
number |
30000 |
Per-request timeout (max 10 MiB response) |
Returns {status, headers, body, error}:
var resp = goa.http.fetch("https://api.anthropic.com/v1/usage", {
method: "GET",
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
timeoutMs: 15000
});
if (resp.error) {
// Network error, policy refusal, or timeout
return { error: resp.error };
}
if (resp.status === 401 || resp.status === 403) {
return { error: "auth_required" };
}
var data = JSON.parse(resp.body);
goa.storage
Per-plugin persistent key/value storage. Backed by
~/.goa/plugins/<id>/storage.json (mode 0600, atomic writes via tmp+rename).
Values are strings — the plugin is responsible for JSON serialization.
goa.storage.set("access_token", "sk-ant-...");
var token = goa.storage.get("access_token"); // "" when absent
var exists = goa.storage.get("refresh_token"); // "" when absent
goa.storage.delete("access_token");
var allKeys = goa.storage.keys(); // ["refresh_token", ...]
Credential safety: Storage files use
0600permissions. The bridge never interprets values — they are opaque strings. OAuth tokens, API keys, and any other secrets should go here, not in plugin code.
goa.setInterval(fn, ms) / goa.setTimeout(fn, ms)
Timers for polling and one-shot delays. ms is clamped to a minimum of
250ms so a buggy plugin cannot busy-spin the JS runtime. Each returns a
numeric timer id.
var id = goa.setInterval(function() {
refreshAllDue(false);
goa.ui.refreshSegment("quota");
}, 60000);
| Function | Behavior | Returns |
|---|---|---|
goa.setInterval(cb, ms) |
Repeating timer, clamped ≥250ms | Timer ID (number) |
goa.setTimeout(cb, ms) |
One-shot timer | Timer ID (number) |
goa.clearInterval(id) |
Cancel a repeating timer | — |
goa.clearTimeout(id) |
Cancel a one-shot timer | — |
Timer callbacks run on the plugin VM under the global lock. They can call
bridges (http.fetch, storage, ui.refreshSegment) safely. Panics are
contained — a crashing callback does not crash the app.
goa.ui.addSegment({id, priority, render})
Adds a status-bar segment (footer) to the TUI. The render() function returns
a string or {text, color} object. Crucially, render() must be a pure
cache read — it is called on the render path and must never fetch or block.
| Parameter | Type | Description |
|---|---|---|
id |
string |
Unique segment identifier |
priority |
number |
Display order (lower = earlier in the footer) |
render |
function() |
Returns string or {text, color} |
goa.ui.addSegment({
id: "quota",
priority: 10,
render: function() {
return _cache.active ? "[8%|24%]" : "";
}
});
Return value conventions:
- Plain string — rendered as-is in the footer
{text, color}—textis the display string;colornames a semantic color ("ok","warn","critical","pending") resolved through the active theme viagoa.segmentColor. Unknown/absent colors render unstyled.- Pre-colored string — you can emit raw ANSI codes using
ansiWrap()(see the provider-quota plugin'scolorizedSegment), but this bypasses theme support. - Empty string
""— the segment is hidden (no space consumed in footer)
goa.ui.refreshSegment(id)
Signals that a segment's rendered content changed; the footer re-renders it on the next frame. Non-blocking (drops oldest intent when channel is saturated).
goa.ui.refreshSegment("quota");
goa.ui.addPane({id, title, render})
Adds a pane to the TUI layout (multi-pane view). Less commonly used than segments.
goa.ui.addModal({id, title, render})
Adds a modal dialog.
goa.ui.confirm({title, body, options, defaultId, allowCancel, timeoutMs})
Asks the user a multiple-choice question and blocks until they answer (plugins plan §4). The TUI shows a modal selection card — one at a time, FIFO-serialized; while it is up, other plugin timers/segments pause (they re-fire after the answer) because two JavaScript frames must never run on one runtime.
const r = goa.ui.confirm({
title: "Use rate-limit reset?",
body: "This consumes one credit.",
options: [
{ id: "yes", label: "Yes, use reset", style: "danger" },
{ id: "no", label: "Not now" },
],
defaultId: "no",
allowCancel: true,
timeoutMs: 60000,
});
if (r.error) { /* timeout / no UI / bad spec */ }
else if (r.cancelled) { /* user dismissed */ }
else { goa.output("chose " + r.id); }
| Parameter | Type | Description |
|---|---|---|
title |
string |
Card heading |
body |
string |
Explanatory text (optional) |
options |
array |
≥1 {id, label, style} rows; style = ok | danger | default |
defaultId |
string |
Initially highlighted option |
allowCancel |
boolean |
Adds an implicit Cancel row + Esc dismissal |
timeoutMs |
number |
Wait bound; 0/absent = capped at 5 minutes |
Return shape: {id} on a real choice, {cancelled:true} on dismissal, or
{cancelled:true, error} when the wait failed (timeout, no-ui) or the
spec was invalid. Without a visible TUI (headless runs) the call fails closed
immediately with {cancelled:true, error:"no-ui"} — treat cancellation as
"the user declined", never as consent. Confirm is display + choice only:
option IDs are opaque strings handed back to the plugin; no capability is
granted by answering, and external plugins must declare the ui-confirm
permission in their manifest (validated at load) to call it — Goa's bundled
provider-quota plugin declares it for its /quota:reset credit flow. Calls
from a command context are safe
(plugin commands execute off the UI thread); calling from code that runs on
the TUI thread itself fails closed with an error instead of freezing the UI.
Note: The UI bridge is namespaced under
goa.ui— usegoa.ui.addSegment(...),goa.ui.addPane(...),goa.ui.addModal(...),goa.ui.refreshSegment(id), andgoa.ui.confirm(...). The baregoa.addSegment/goa.addPane/goa.addModalforms are deprecated aliases.
goa.registerHotkey({key, ctrl, alt, shift, description, handler})
Registers a keyboard shortcut. Built-in Go bindings take precedence — a plugin key that collides with a built-in simply never fires.
| Parameter | Type | Description |
|---|---|---|
key |
string |
Base key name (e.g., "q", "f5") |
ctrl |
boolean |
Ctrl modifier |
alt |
boolean |
Alt modifier |
shift |
boolean |
Shift modifier |
description |
string |
Human-readable description for /hotkeys |
handler |
function() |
Called when the shortcut is pressed |
goa.registerHotkey({
key: "q", ctrl: true, shift: true,
description: "Refresh provider quota",
handler: function() {
refreshAllDue(true);
goa.ui.refreshSegment("quota");
}
});
goa.openBrowser(url)
Opens an http(s) URL in the user's default browser. Supports macOS (open),
linux/bsd (xdg-open), and windows (rundll32). Best-effort — always also
print the URL via goa.output as a fallback for headless sessions.
goa.openBrowser("https://api.anthropic.com/settings/billing");
goa.output("Open this URL in your browser:\n " + url);
goa.output(message)
Writes a user-visible message into the conversation viewport (as an output modal), not the log. Use for OAuth instructions, login results, and any other user-facing text.
goa.output("Authorize provider quota access:\n https://auth.example.com/device\nEnter code: ABCD-1234");
Concurrency Model
All JavaScript across all plugins is serialized behind a single global VM lock — Goja runtimes are not goroutine-safe, and plugins have asynchronous entry points (timers, hotkeys, tool exec, commands).
- Bridge calls (
http.fetch,storage.*) block only the calling JS flow while the Go-side operation runs on a background goroutine. - Timers and hotkeys that arrive while the VM is busy wait their turn on the mutex.
- Segment
render()is called from the TUI render loop, which acquires the VM lock. Ifrender()blocks (fetches, waits), the entire TUI freezes.
Rule: Segment render() must be a pure cache read. Use timers to refresh
data and ui.refreshSegment(id) to signal a repaint.
Panic containment
Timer callbacks, hotkey handlers, and segment renders are wrapped in
recover() so a misbehaving plugin cannot crash the app. The error is logged
and execution continues.
Module Loading (require)
Plugins use a scoped CommonJS require() for multi-file projects:
// plugin.js
var format = require("./lib/format.js");
var oauth = require("./lib/oauth.js");
var anthropic = require("./fetchers/anthropic.js");
Rules
- Relative paths resolve against the requiring module's directory (Node semantics).
- Module exports use
exports.foo = ...ormodule.exports = {…}. - Cache is per-plugin and per-load: repeated
require()calls return the same exports object. This supports shared mutable state (e.g., a fetcher registry) and circular imports (cache is populated before execution). - Path confinement —
requirecannot escape the plugin directory. Arequire("../../etc/passwd")attempt throws a JS error. - Extension —
.jsis appended automatically when omitted.
// lib/format.js — pure functions, no goa.* deps (trivially testable)
exports.pct = function(used, limit) {
return Math.round((used / limit) * 100);
};
exports.tokens = function(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(Math.round(n));
};
Bundled Plugins
Goa ships built-in plugins embedded in the binary via Go's //go:embed. The
current bundled plugin is provider-quota (in plugins/bundled/provider-quota/).
How bundled loading works
- Materialization — On startup, each bundled plugin is copied from the
embed FS into
~/.goa/plugins/bundled/<id>@<version>/. The versioned directory name means an upgraded binary (with a bumped plugin version) materializes into a new directory. - Stale-version pruning — After every materialization (including the
reuse fast path), the manager deletes all other
<id>@*directories of the same plugin id. Leftover old versions are not harmless: the loader scans the whole bundled dir, and a stale copy's VM registers commands first — first registration wins, so/quota-style commands would dispatch to OLD code while the current version still ran in parallel. Pruning keeps exactly one dir per bundled id; a loader-side duplicate-id guard skips (with a loud log) any id discovered twice, whatever the cause. - Content validation — The materialized copy is SHA-256 hashed and recorded in the lockfile. Every subsequent startup verifies the hash; tampered or stale copies are re-materialized from the trusted embed.
- Automatic enablement — Bundled plugins are enabled automatically (no trust prompt).
- Disable via config:
plugins:
bundled:
provider-quota: false
Creating a new bundled plugin
- Create a directory under
plugins/bundled/<name>/withplugin.yamlandplugin.js. - Add the embed directive in
plugins/bundled/bundled.go://go:embed <name> var FS embed.FS - Register a source function (like
ProviderQuotaSource()) returning the plugin's id and version. - Wire it in
internal/app/plugins.gomaterializeBundledPlugins().
Plugin Management (/plugin)
Goa provides the /plugin command for managing plugins:
| Command | Description |
|---|---|
/plugin |
Interactive selector: list installed plugins and toggle enabled/disabled with Enter |
/plugin:list |
List installed plugins as text with status and hash |
/plugin:install:<git-url> |
Install a plugin from a git URL (cloned, validated, recorded in lockfile) |
/plugin:remove:<id> |
Uninstall a plugin |
/plugin:enable:<id> |
Activate an installed plugin (trust-gated; use /trust:<id> first if needed) |
/plugin:disable:<id> |
Deactivate an enabled plugin |
Colon syntax: Goa uses colons for arguments (
/plugin:enable:provider-quota), not spaces. Tab completion after/plugin:enable://plugin:disable:offers only plugins in the matching state.
Installation workflow
/plugin:install:https://github.com/user/my-plugin.git— clones, validates manifest, records content hash. Plugin starts disabled./trust:my-plugin(if not yet trusted) — required for plugins from new sources./plugin:enable:my-plugin— activates the plugin (reload required unless called from the running session)./reload— loads the plugin's JS runtime into the current session.
Trust system
Plugins are permission-gated via the trust system. Untrusted plugins cannot be enabled — Goa prompts for trust approval:
/trust:my-plugin # Approve the plugin for this project/user
/trust:revoke:my-plugin # Revoke trust
Trust status is persisted in the trust store and checked on every enable attempt. Bundled plugins are always trusted.
Integrity verification
Every install computes a SHA-256 content hash over all files in the plugin
directory. Enable() re-verifies the hash before activating. If the plugin
directory is modified after install (tampered or corrupted), enable fails:
plugin provider-quota integrity check failed: content changed since install
Re-install to fix: /plugin:remove:<id> then /plugin:install:<url>.
Reference Implementation: provider-quota
The provider-quota plugin is the canonical reference implementation for
Goa plugins. It demonstrates every major API and architectural pattern. Its
source lives at plugins/bundled/provider-quota/.
What it does
- Tracks usage/quota for all configured LLM providers (Anthropic, OpenAI, Codex, Z.ai, Kimi, MiniMax, OpenRouter, OpenCode, plus a local/inferred fallback).
- Shows a compact, color-coded quota segment in the footer status bar.
- Provides a
/quotacommand with subcommands for detailed breakdowns, JSON export, OAuth management, and Codex rate-limit reset credits (/quota:resetslists them;/quota:reset[:<credit-id>]consumes one behind a danger-styledgoa.ui.confirm— the count and the credits table also render inline in the global/quotabreakdown). A one-time startup notice reports available credits; hitting a rate limit on a Codex model triggers a debounced hint (≥10 min between hints) after refreshing the codex entry. - Registers argument completions for all
/quotasubcommands plus per- provider refresh/login/logout levels (goa.registerCompletion). - Refreshes data on a background timer (every 60s) and on explicit request
(
Ctrl+Shift+Qor/quota:refresh).
z.ai Coding Plan reset credits
Beyond consumption, a z.ai Coding Plan account holds reset credits that clear
an exhausted 5-hour or weekly window. /quota:resets lists them,
/quota:reset[:week] consumes one, and /quota:reset:claim asks for a new
grant.
This surface is dual-authenticated and needs credentials the plain z.ai API key does not provide:
| Credential | Header | Source |
|---|---|---|
| zcode business JWT | Authorization: Bearer … |
/login:zai:oauth |
| family OAuth access token | X-Bigmodel-Authorization (raw, no Bearer) |
/login:zai:oauth |
Sign in once: /login:zai:oauth (or /login:bigmodel:oauth for the CN
family) mints BOTH credentials in a single browser authorization and stores them
in Goa's auth store. /quota:resets then works with no further configuration.
The two accounts are independent: api_key powers the consumption row (it
authenticates the monitor API), while the reset surface needs the OAuth pair.
When the pair is missing, /quota says so explicitly rather than reporting zero
available resets — a fetch that never authenticated must not read as an account
that holds none.
The family token is resolved strictly for the account's own family (zai vs
bigmodel, decided by the endpoint) with no cross-family fallback: it is a
different identity, and the API validates it against the account's family.
Reading the credential from a plugin
goa.auth.oauthToken(family) (permission oauth-token) returns the managed
pair in ONE call, so a plugin never has to pair a fresh token with a stale JWT:
var cred = goa.auth.oauthToken("zai"); // {accessToken, zcodeJwt, accountId}
if (cred && !cred.error) { /* cred.accessToken + cred.zcodeJwt */ }
An absent login is returned as an error (OAuth login required for zai (use /login:zai:oauth)), which is the signal to fall back to config.
Provider extra keys are ALSO passed through to goa.config().providers[…],
under the structural fields (id, name, provider, apiKey, baseUrl,
endpoint), which always win a name collision — an escape hatch for a second
account or a CI token that should override the stored login. See
pluginProvidersMap in internal/app/plugins.go.
providers:
- id: z.ai
provider: zai
api_key: ${ZAI_API_KEY} # consumption quota (the monitor API)
endpoint: https://api.z.ai/api/coding/paas/v4
extra: # optional override of the /login credential
zcodeJwt: ${ZCODE_JWT}
accessToken: ${ZAI_OAUTH}
Architecture (for plugin authors to study)
plugin.js — Entry point: registry, scheduler, /quota command
├── lib/format.js — Pure formatting functions (no goa.* deps)
├── lib/http-quota.js — Generic HTTP quota-fetch engine (SOLID: Open/Closed)
├── lib/oauth.js — OAuth device-code flow + token refresh
└── fetchers/*.js — Per-provider descriptors (Anthropic, OpenAI, etc.)
Pattern 1: Fetcher registry
The plugin maintains a registry of provider fetchers, each implementing a
fetch(ctx) function and metadata (name, auth, refreshInterval,
quotaEndpoint). Adding a new provider = adding one file + one register()
call:
// fetchers/newprovider.js
var hq = require("../lib/http-quota.js");
var desc = {
auth: hq.apiKeyAuth().auth,
url: function(ctx) { return "https://api.example.com/v1/usage"; },
map: hq.windowedUsageMapper({...})
};
function fetch(ctx) { return hq.runFetch(desc, ctx); }
module.exports = {
name: "New Provider", auth: { type: "api_key" },
refreshInterval: 300000, quotaEndpoint: true, fetch: fetch
};
Pattern 2: Separate fetch engine from providers
lib/http-quota.js implements the entire HTTP fetch pipeline once — auth
resolution, GET request, status/parse error mapping, and result shaping.
Individual fetchers supply only a descriptor {auth, url, headers, map},
following the Open/Closed Principle:
// descriptor shape (see lib/http-quota.js)
{
auth: function(ctx) -> string|null, // resolve bearer/api key
authError: "no_api_key"|"auth_required", // error when auth() is null
url: function(ctx) -> string, // full request URL
headers: function(ctx, token) -> object, // request headers
map: function(body, ctx) -> result // shape {plan, limits, costUnit?}
}
Pattern 3: Cache-read segment render
The status segment render() function reads from _cache[id] only. A
goa.setInterval(…, 60000) timer calls refreshAllDue(false) to update the
cache, then signals goa.ui.refreshSegment("quota") to repaint. This ensures
the TUI render path is never blocked by network.
Pattern 4: Theme-aware coloring via goa.segmentColor
Each quota window is colored by its projected window-end usage ratio:
ratio < 0.8→"ok"(green)ratio < 1.0→"warn"(orange)ratio >= 1.0→"critical"(red)
The plugin calls goa.segmentColor(name) to get the theme's hex, then wraps
the percentage text in ANSI codes. If segmentColor is unavailable, it falls
back to a single worst-window color.
Pattern 5: OAuth device-code flow
lib/oauth.js demonstrates a complete async flow using polling:
- Request a device code via
goa.http.fetch - Print and open the verification URL via
goa.output+goa.openBrowser - Poll for token completion via
goa.setInterval - Store credentials via
goa.storage - Transparent token refresh before expiry
Pattern 6: Error vocabulary
The plugin uses a shared error vocabulary that the status segment understands:
| Error string | Meaning |
|---|---|
"no_api_key" |
Provider configured without the required API key |
"auth_required" |
OAuth token missing, expired, or 401/403 |
"http_<status>" |
Non-200 response from the quota API |
"bad_response" |
Transport error or unparseable response body |
What the quota plugin demonstrates
| Feature | Where used |
|---|---|
goa.registerCommand() |
/quota command with colon subcommands |
goa.registerCompletion() |
Subcommand + provider completions for /quota: |
goa.ui.confirm() |
Rate-limit reset consumption (danger-styled, Cancel default) |
goa.registerObserver() |
rate_limit_exceeded → debounced reset hint |
goa.output() |
Startup reset-credit notice, async quota renders, reset outcomes |
goa.ui.addSegment() |
Status-bar quota display |
goa.ui.refreshSegment() |
Signal segment repaints after refresh |
goa.registerHotkey() |
Ctrl+Shift+Q force-refresh |
goa.setInterval() / goa.setTimeout() |
Background refresh scheduler; off-command-path fetches |
goa.http.fetch() |
Quota API calls per provider (usage, reset details, consume) |
goa.storage |
OAuth token persistence |
goa.sessionUsage() |
Local/inferred token counts |
goa.segmentColor() |
Theme-aware per-window coloring |
goa.config() |
Live provider config (keys, endpoints, active provider) |
goa.output() |
OAuth login instructions in viewport |
goa.openBrowser() |
OAuth verification URL |
require() |
Multi-file module structure |
plugin.yaml permissions |
provider-keys permission to access API keys |
Installing Third-Party Plugins
From a git repository
# Install (clones, validates, records hash)
/plugin:install:https://github.com/user/my-plugin.git
# Trust (first time only)
/trust:my-plugin
# Enable
/plugin:enable:my-plugin
# Reload to activate
/reload
From a local directory
Place the plugin directory in one of:
| Location | Scope | Override |
|---|---|---|
~/.goa/plugins/<id>/ |
User-global (all projects) | Lowest |
.goa/plugins/<id>/ |
Project-local | Highest |
Then enable in config:
plugins:
enabled:
- my-plugin
Via git source (the /plugin:install command does this automatically):
- Clones to a temp directory
- Validates
plugin.yaml(requiresidandname) - Computes content hash over all plugin files
- Moves to
~/.goa/plugins/<id>/ - Records in
~/.goa/plugins/plugin.lock - Leaves the plugin disabled — run
/plugin:enable:<id>to activate
Writing a Plugin (Development Guide)
This section contains everything Goa needs to autonomously create a new plugin. Follow these steps and patterns.
Step 1: Create the directory structure
my-plugin/
├── plugin.yaml
├── plugin.js
├── lib/ # Optional: modules
└── skills/ # Optional: SKILL.md files
Step 2: Write the manifest
id: my-plugin
name: My Plugin
version: 1.0.0
description: What this plugin does
entry: plugin.js
# permissions:
# - provider-keys # Uncomment if you need API keys from goa.config()
Step 3: Write the entry point
// plugin.js — entry point
var log = goa.logger();
// --- Registration ---
goa.registerTool({ … });
goa.registerCommand({ … });
goa.registerObserver(function(event, payload) { … });
// --- Scheduling (optional) ---
goa.setInterval(function() {
// Refresh data, then:
goa.ui.refreshSegment("my-segment");
}, 60000);
log.info("my-plugin loaded");
Step 4: Add a status segment
var _cache = {};
goa.ui.addSegment({
id: "my-plugin",
priority: 20,
render: function() {
// READ CACHE ONLY — never fetch here
if (!_cache.value) return "";
return { text: "[" + _cache.value + "%]", color: budgetColor(_cache.value) };
}
});
Step 5: Use modules for organization
// lib/helpers.js
exports.compute = function(x) { return x * 2; };
// plugin.js
var helpers = require("./lib/helpers.js");
Step 6: Handle errors properly
execute: function(params) {
try {
var result = doWork(params);
return JSON.stringify(result);
} catch (e) {
return "[tool error: my-plugin]\n" + e.message + "\nHint: check your input";
}
}
Step 7: Use permissions correctly
If your plugin needs to read API keys from goa.config():
- Add
permissions: [provider-keys]toplugin.yaml - The keys are then unmasked in the config object
Permission design rationale: API keys are masked by default so a plugin that
never declared provider-keys cannot exfiltrate credentials. The permission
is declared in the manifest, reviewed at install time via the trust system.
Complete plugin template
plugin.yaml:
id: my-plugin
name: My Plugin
version: 1.0.0
description: Template plugin demonstrating core APIs
entry: plugin.js
plugin.js:
// --- Logger ---
var log = goa.logger();
// --- State ---
var _state = { counter: 0 };
// --- Tool ---
goa.registerTool({
name: "my_tool",
description: "Demonstrates a plugin tool",
execute: function(params) {
_state.counter++;
return "Tool called with: " + JSON.stringify(params);
}
});
// --- Command ---
goa.registerCommand({
name: "mystats",
shortHelp: "Show plugin statistics",
run: function(args) {
return "my-plugin stats:\n calls: " + _state.counter;
}
});
// --- Observer ---
goa.registerObserver(function(event, payload) {
if (event === "tool.call") {
log.info("Tool called: " + payload.name);
}
});
// --- Hotkey (Ctrl+Shift+M) ---
goa.registerHotkey({
key: "m", ctrl: true, shift: true,
description: "My plugin action",
handler: function() {
log.info("Hotkey pressed");
}
});
log.info("my-plugin loaded");
Complete Example
The following plugin registers a word_count tool, a /stats command, and
observes tool.call events to track usage.
plugin.yaml:
id: stats
name: Stats Plugin
version: 1.0.0
description: Tracks tool usage and provides word counting
entry: plugin.js
plugin.js:
var log = goa.logger();
var toolCalls = {};
goa.registerTool({
name: "word_count",
description: "Count the number of words in the provided text",
execute: function(params) {
var text = params.text || "";
var words = text.trim().split(/\s+/);
return words.length + " words";
}
});
goa.registerCommand({
name: "stats",
shortHelp: "Show plugin usage statistics",
run: function(args) {
var result = "Plugin Stats:\n";
result += " Tools registered: word_count\n";
result += " Tool calls tracked: " + Object.keys(toolCalls).length + "\n";
for (var name in toolCalls) {
result += " " + name + ": " + toolCalls[name] + " calls\n";
}
return result;
}
});
goa.registerObserver(function(event, payload) {
if (event === "tool.call") {
var name = payload.name;
toolCalls[name] = (toolCalls[name] || 0) + 1;
log.info("Tool called: " + name);
}
});
log.info("Stats plugin loaded");
Best Practices
-
Use
goa.logger()for debugging — it's routed to Goa's logging system and respects log level configuration. -
Segment render() must be a pure cache read — never fetch, compute, or block inside
render(). Use timers for data refresh andgoa.ui.refreshSegment(id)to trigger repaints. -
Keep execute functions synchronous — Goja does not support async/await or Promises natively. For async operations, use
goa.http.fetchwhich blocks only the calling JS flow. -
Use an error vocabulary for the status segment — return descriptive error strings (
"no_api_key","auth_required","http_<status>") that the segment render can display as human-readable warnings. -
Follow Goa's error format for tools:
[tool error: type]\n<detail>\nHint: <action> -
Use namespaced tool/command names — prefix with your plugin ID to avoid collisions:
myplugin_my_tool. -
Keep plugins stateless where possible — the runtime may be recreated on reload. Use
goa.storagefor persistence. -
Prefer
goa.segmentColor(name)over raw ANSI — using semantic color names makes your plugin respect the user's theme choice. -
Test with
goa.callTool()before releasing — you can test tool registration from another command or plugin. -
Structure by concern — follow the provider-quota pattern: separate fetch engine from provider specifics, keep formatting functions pure, use modules for organization.
Limitations
- No DOM/browser APIs — Goja is an ES5.1+ engine with limited ES6 support.
Promise,Map,Set,Proxyare not available. - No direct filesystem access — use Goa's tools (
read,edit,bash) viagoa.callTool(). - No arbitrary network access — use
goa.http.fetch(HTTPS only except loopback HTTP). - Async operations — use blocking bridges (
http.fetch,storage), not Promises or async/await. - Plugin hot-reload is not yet implemented —
/reloadre-scans directories but does not stop old JS runtimes. Restart Goa to fully reload plugins (future work).
Troubleshooting
| Symptom | Likely Cause | Solution |
|---|---|---|
| Plugin not loaded | plugin.yaml missing or invalid |
Check YAML syntax; ensure id and name are present |
goa is not defined |
Plugin loaded outside Goja runtime | Plugins only work inside Goa's JSBridge |
| Tool not available to agent | Plugin not enabled in config | Check plugins.enabled in your config file |
| Command not found | /reload not run |
Run /reload after installing/enabling plugins |
| JS syntax error | ES6+ feature used | Stick to ES5.1 syntax (var, function, no arrow functions) |
| Segment not updating | render() not reading fresh cache |
Verify your timer calls goa.ui.refreshSegment(id) |
goa.http.fetch returns auth_required |
OAuth token expired | Run /quota:login:<provider> or check API key in config |
| Plugin enable fails: "not trusted" | Plugin not approved by trust system | Run /trust:<id> to approve |
| Plugin enable fails: "integrity check failed" | Plugin files modified after install | Re-install with /plugin:remove:<id> then /plugin:install:<url> |
API keys empty in goa.config() |
Plugin missing provider-keys permission |
Add permissions: [provider-keys] to plugin.yaml |