| title | Plugins |
|---|
AWF supports plugins to extend functionality with custom operations. AWF ships with built-in plugins for HTTP requests, GitHub operations, and notifications, and supports external RPC plugins for additional integrations.
AWF includes a built-in GitHub operation provider that offers 8 declarative operations for interacting with GitHub issues, pull requests, labels, and comments. Unlike external RPC plugins, the GitHub plugin runs in-process with zero IPC overhead.
Key features:
- 8 operations:
get_issue,get_pr,create_issue,create_pr,add_labels,add_comment,list_comments,batch - Automatic authentication via
ghCLI orGITHUB_TOKENenvironment variable - Repository auto-detection from git remote
- Batch execution with configurable concurrency and failure strategies
get_issue:
type: operation
operation: github.get_issue
inputs:
number: 42
on_success: process
on_failure: errorSee Workflow Syntax - Operation State for complete reference and examples.
AWF includes a built-in HTTP operation provider that enables declarative REST API calls without shell commands. The http.request operation supports standard HTTP methods and captures structured responses for conditional routing.
Key features:
- 4 HTTP methods: GET, POST, PUT, DELETE
- Configurable timeout (default 30 seconds)
- Response capture: status code, body, headers
- Template interpolation in URL, headers, and body
- Retryable status codes for transient failures (429, 502, 503, etc.)
- 1MB response body limit to prevent memory exhaustion
fetch_user:
type: operation
operation: http.request
inputs:
method: GET
url: "https://api.example.com/users/{{.inputs.user_id}}"
headers:
Authorization: "Bearer {{.inputs.api_token}}"
Accept: "application/json"
timeout: 10
on_success: process
on_failure: error| Input | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | HTTP endpoint URL (must start with http:// or https://) |
method |
string | Yes | HTTP method: GET, POST, PUT, DELETE |
headers |
object | No | Custom headers as key-value pairs |
body |
string | No | Request body (for POST/PUT) |
timeout |
integer | No | Request timeout in seconds (default: 30) |
retryable_status_codes |
array | No | Status codes triggering retries (e.g., [429, 502, 503]) |
| Output | Type | Description |
|---|---|---|
status_code |
integer | HTTP response status (200, 404, 503, etc.) |
body |
string | Response body (truncated at 1MB) |
headers |
object | Response headers (canonicalized names, multi-value joined with , ) |
body_truncated |
boolean | true if the response body exceeded 1MB and was truncated |
GET Request with Response Access:
fetch_data:
type: operation
operation: http.request
inputs:
method: GET
url: "https://api.example.com/status"
headers:
Accept: "application/json"
on_success: process
on_failure: errorPOST with Retry:
create_resource:
type: operation
operation: http.request
inputs:
method: POST
url: "https://api.example.com/resources"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{.inputs.api_token}}"
body: '{"name": "{{.inputs.resource_name}}", "owner": "{{.inputs.user_id}}"}'
timeout: 15
retryable_status_codes: [429, 502, 503]
retry:
max_attempts: 3
backoff: exponential
initial_delay: 1s
on_success: success
on_failure: errorMulti-Step Workflow with Response Capture:
name: fetch-and-process
version: "1.0.0"
inputs:
- name: api_url
type: string
required: true
- name: api_key
type: string
required: true
states:
initial: fetch
fetch:
type: operation
operation: http.request
inputs:
method: GET
url: "{{.inputs.api_url}}"
headers:
Authorization: "Bearer {{.inputs.api_key}}"
on_success: process_response
on_failure: handle_error
process_response:
type: step
command: echo "Got status {{.states.fetch.Response.status_code}}: {{.states.fetch.Response.body}}"
on_success: done
handle_error:
type: terminal
status: failure
done:
type: terminal
status: successSee Workflow Syntax - HTTP Operations for complete reference.
AWF includes a built-in notification provider that sends alerts when workflows complete. It exposes a single notify.send operation that dispatches to two backends: desktop notifications and generic webhooks.
Key features:
- 1 operation:
notify.sendwith backend dispatch - 2 backends:
desktop,webhook - 10-second HTTP timeout for network backends (prevents workflow stalls)
- Platform detection for desktop notifications (
notify-sendon Linux,osascripton macOS) - All inputs support AWF template interpolation (
{{workflow.name}},{{workflow.duration}}, etc.)
notify_team:
type: operation
operation: notify.send
inputs:
backend: desktop
title: "Build Complete"
message: "{{workflow.name}} finished in {{workflow.duration}}"
on_success: done
on_failure: error| Backend | Transport | Required Config | Required Inputs |
|---|---|---|---|
desktop |
OS-native (notify-send / osascript) |
None | message |
webhook |
HTTP POST to arbitrary URL | None | message, webhook_url |
| Input | Type | Required | Description |
|---|---|---|---|
backend |
string | Yes | Notification backend: desktop, webhook |
message |
string | Yes | Notification message body |
title |
string | No | Notification title (defaults to "AWF Workflow") |
priority |
string | No | Priority: low, default, high (defaults to default) |
webhook_url |
string | No | Webhook URL (required for webhook backend) |
| Output | Type | Description |
|---|---|---|
backend |
string | Which backend handled the notification |
status |
string | HTTP status code (network backends) or confirmation |
response |
string | Response body or confirmation message |
Configure notification backends in .awf/config.yaml:
plugins:
notify:
default_backend: "desktop"| Config Key | Description |
|---|---|
default_backend |
Backend to use when backend input is omitted |
When both a config default_backend and an explicit backend input are set, the explicit input takes precedence.
Desktop - Uses notify-send on Linux and osascript -e 'display notification' on macOS. Fails gracefully on unsupported platforms (e.g., headless servers).
Webhook - Sends a generic JSON POST to any URL. The payload includes workflow, status, duration, message, and outputs fields. Use this for ntfy, Slack, Discord, Teams, PagerDuty, or any HTTP integration.
See Workflow Syntax - Notification Operations for complete examples.
External plugins are standalone executables that communicate with AWF via RPC (HashiCorp go-plugin). This architecture provides:
- Process isolation - Plugins run in separate processes
- Cross-platform support - No CGO or platform-specific binaries
- Safe updates - Replace plugins without recompiling AWF
- Graceful failures - Plugin crashes don't affect AWF core
AWF discovers plugins from:
$XDG_DATA_HOME/awf/plugins/ # Default: ~/.local/share/awf/plugins/
Each plugin must have:
- A
plugin.yamlmanifest file - An executable binary (named
awf-plugin-<name>)
plugins/
└── awf-plugin-github/
├── plugin.yaml # Plugin manifest
└── awf-plugin-github # Executable binary
Every plugin requires a plugin.yaml manifest:
name: awf-plugin-github
version: 1.0.0
description: GitHub integration for AWF workflows
awf_version: ">=0.4.0"
capabilities:
- operations
config:
token:
type: string
required: true
description: GitHub API token| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Plugin identifier (must match directory name) |
version |
string | Yes | Semantic version |
description |
string | No | Brief description |
awf_version |
string | Yes | AWF version constraint (semver) |
capabilities |
array | Yes | List: operations, step_types, validators, events |
events |
object | No | Event subscriptions and emissions (see Plugin Events) |
config |
object | No | Configuration schema |
| Field | Type | Description |
|---|---|---|
type |
string | string, integer, boolean |
required |
bool | If true, must be configured |
default |
any | Default value |
description |
string | Help text |
awf plugin listOutput shows all plugins (built-in and external) with their status and source:
NAME TYPE VERSION STATUS ENABLED CAPABILITIES SOURCE
github builtin dev builtin yes operations -
http builtin dev builtin yes operations -
notify builtin dev builtin yes operations -
awf-plugin-jira external 2.1.0 enabled yes operations myorg/awf-plugin-jira
awf-plugin-metrics external 1.0.0 disabled no operations acme/awf-plugin-metrics
The SOURCE column shows the GitHub owner/repo for plugins installed via awf plugin install. Manually installed plugins and built-in plugins show -.
Install an external plugin from GitHub Releases:
awf plugin install owner/repoAWF downloads the latest release, verifies the SHA-256 checksum, extracts the archive, validates the manifest, and installs atomically.
Flags:
| Flag | Description |
|---|---|
--version |
Version constraint (e.g. ">=1.0.0 <2.0.0") |
--pre-release |
Include alpha/beta/rc versions in resolution |
--force |
Overwrite an existing installation |
Examples:
# Install latest stable release
awf plugin install myorg/awf-plugin-jira
# Install with version constraint
awf plugin install myorg/awf-plugin-jira --version ">=1.0.0 <2.0.0"
# Include pre-release versions
awf plugin install myorg/awf-plugin-jira --pre-release
# Reinstall (overwrite existing)
awf plugin install myorg/awf-plugin-jira --forceThe owner/repo argument must be a GitHub repository path (not a URL). The repository must contain GitHub Releases with .tar.gz assets matching the AWF naming convention (see Release Asset Naming).
Update an installed plugin to the latest version:
awf plugin update <name>AWF fetches the latest release from the plugin's source repository, verifies the checksum, and performs an atomic replacement.
Flags:
| Flag | Description |
|---|---|
--all |
Update all externally installed plugins |
Examples:
# Update a specific plugin
awf plugin update jira
# Update all external plugins
awf plugin update --allRunning awf plugin update without a plugin name and without --all returns a usage error.
Remove an installed external plugin:
awf plugin remove <name>AWF shuts down the plugin process, removes the plugin directory, and clears its state.
Flags:
| Flag | Description |
|---|---|
--keep-data |
Preserve plugin configuration and state |
Examples:
# Remove a plugin
awf plugin remove jira
# Remove but keep configuration
awf plugin remove jira --keep-dataBuilt-in plugins cannot be removed. Attempting to remove a built-in plugin returns an error with a hint to use awf plugin disable instead.
Search for AWF plugins on GitHub:
awf plugin search [query]Searches GitHub repositories tagged with the awf-plugin topic. Results include repository name, description, and latest version.
# Search all AWF plugins
awf plugin search
# Search with a keyword
awf plugin search jiraUse --output=json for machine-readable output.
# Disable a plugin
awf plugin disable awf-plugin-github
# Enable a plugin
awf plugin enable awf-plugin-githubPlugin state persists across AWF restarts.
Verify that installed plugin binaries have not been modified or corrupted:
# Verify all plugins
awf plugin verify
# Verify a specific plugin
awf plugin verify jira
# Verify and update stored checksums (useful for manually installed or local plugins)
awf plugin verify jira --updateThe verify command checks the SHA-256 checksum of each plugin binary against a stored value. For plugins installed via awf plugin install, the checksum is recorded automatically at install time. For manually placed or locally-built plugins, use --update to compute and store their checksums.
Output example:
Plugin Status Expected Hash Actual Hash
awf-plugin-jira ✓ pass a3f9d4c5e8b2f1g7h8i9j0k1l2m3n4o5p a3f9d4c5e8b2f1g7h8i9j0k1l2m3n4o5p
awf-plugin-metrics ✗ fail b2e8c3d7f6a4h5i9j2k3l4m5n6o7p8q9r x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5m6n7
awf-plugin-custom ! miss (no stored checksum) c9h8i7j6k5l4m3n2o1p0q1r2s3t4u5v6w
- ✓ pass - Binary matches the stored checksum (integrity verified)
- ✗ fail - Binary does not match; the plugin may be corrupted or tampered with
- ! miss - No checksum stored; plugin will launch without verification (use
--updateto enable verification)
AWF implements multiple security layers for plugin execution:
All host-plugin communication uses automatic mutual TLS encryption by default. AWF and plugin binaries automatically generate ephemeral certificates at startup — no manual key management is required.
Benefits:
- Prevents network sniffing of plugin data on shared infrastructure
- Protects secrets passed through plugin communication
- Transparent to end users and plugin authors
Backward compatibility: If a plugin binary is built with an older SDK that doesn't support AutoMTLS, the connection automatically downgrades to plaintext with a warning in the logs. The plugin continues to function.
AWF verifies the SHA-256 checksum of each plugin binary before launching it. This prevents execution of corrupted or tampered binaries.
When verification happens:
- For plugins installed via
awf plugin install: checksum is verified automatically at runtime using the stored value from install time - For manually placed plugins: use
awf plugin verify --updateto enable checksum verification
When verification is skipped:
- Plugins without a stored checksum launch with a warning recommending checksum verification
- This allows existing plugins installed before this feature to continue functioning
Example: Detecting a Tampered Plugin
# After installing a plugin, its checksum is stored
$ awf plugin install myorg/awf-plugin-jira
✓ Installed awf-plugin-jira v1.2.0
# If the binary is modified later (e.g., by disk corruption or supply chain attack)
$ echo "malware" >> ~/.local/share/awf/plugins/awf-plugin-jira/awf-plugin-jira
$ awf run my-workflow
Error: plugin "awf-plugin-jira" checksum mismatch
Expected: a3f9d4c5e8b2f1g7h8i9j0k1l2m3n4o5p
Actual: x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5m6n7
# The plugin is refused and workflow execution stopsPlugin logs and subprocess output (stdout/stderr) are forwarded to AWF's log output with structured context. This aids debugging when plugins crash or behave unexpectedly.
Plugin sources:
- Plugin-emitted structured logs (via hclog)
- Plugin panic output
- Direct writes to stdout/stderr
Log level: Plugin output is forwarded at the INFO level for structured logs and WARN level for panic/output capture. AWF's configured log level (e.g., --quiet, --verbose) filters what appears in the final output.
Example:
$ awf run workflow --verbose
[INFO] Starting plugin awf-plugin-metrics...
[INFO] plugin=awf-plugin-metrics: Listening on port 50051
[INFO] plugin=awf-plugin-metrics: Registered collectors: cpu, memory, disk
[WARN] plugin=awf-plugin-jira: Deprecated API v2 used — upgrade to v3 recommendedPlugins register custom operations that can be used in workflow steps:
name: deploy-with-notification
version: "1.0.0"
states:
initial: deploy
deploy:
type: step
command: ./deploy.sh
on_success: notify
on_failure: error
notify:
type: step
operation: notify.send # Built-in operation
inputs:
backend: webhook
webhook_url: "https://example.com/hooks/deployments"
message: "Deploy completed: {{.states.deploy.Output}}"
on_success: done
on_failure: error
done:
type: terminal
error:
type: terminal
status: failurestep_name:
type: step
operation: <plugin>.<operation>
inputs:
key: valueoperation- Plugin operation in formatplugin_name.operation_nameinputs- Operation-specific parameters (supports variable interpolation)
Two ways to invoke an operation. Beyond the deterministic
operation:step shown above, plugin operations can also be exposed to AI agents at runtime through the MCP proxy. Withmcp_proxy.plugin_tools, the agent receives the operation as a callable MCP tool named<plugin>_<operation>(single underscore, snake_case) and decides when to invoke it. Plugin authors who want their operation to be agent-callable should review the schema constraints in Exposing Operations as MCP Tools.
Configure plugins via environment variables or config file:
# .awf.yaml
plugins:
awf-plugin-github:
token: "${GITHUB_TOKEN}"Environment variables in config values are expanded at runtime.
AWF ships with 3 built-in plugins that always appear in awf plugin list:
$ awf plugin list
NAME TYPE VERSION STATUS ENABLED CAPABILITIES SOURCE
github builtin dev builtin yes operations -
http builtin dev builtin yes operations -
notify builtin dev builtin yes operations -
Built-in plugins can be disabled and re-enabled like external plugins:
awf plugin disable http # Disable the HTTP provider
awf plugin enable http # Re-enable itUse --operations to see operations grouped by plugin:
$ awf plugin list --operations
NAME PLUGIN
github.get_issue github
github.get_pr github
github.create_pr github
github.create_issue github
github.add_labels github
github.list_comments github
github.add_comment github
github.batch github
http.request http
notify.send notify
External plugins are Go binaries that call sdk.Serve() from main(). AWF discovers them via their plugin.yaml manifest and communicates over gRPC using HashiCorp go-plugin.
package main
import (
"context"
"github.com/awf-project/cli/pkg/plugin/sdk"
)
type MyPlugin struct {
sdk.BasePlugin
}
func (p *MyPlugin) Operations() []string {
return []string{"my_op"}
}
func (p *MyPlugin) HandleOperation(_ context.Context, name string, inputs map[string]any) (*sdk.OperationResult, error) {
text := sdk.GetStringDefault(inputs, "text", "")
return sdk.NewSuccessResult(text, nil), nil
}
func main() {
sdk.Serve(&MyPlugin{
BasePlugin: sdk.BasePlugin{
PluginName: "awf-plugin-myplugin",
PluginVersion: "1.0.0",
},
})
}| Helper | Description |
|---|---|
sdk.BasePlugin |
Embed to satisfy sdk.Plugin interface with no-op defaults |
sdk.Serve(p) |
Start the plugin process; blocks until host disconnects |
sdk.NewSuccessResult(output, data) |
Build a success result |
sdk.NewErrorResult(msg) |
Build an error result |
sdk.GetStringDefault(inputs, key, default) |
Extract string input with fallback |
sdk.GetIntDefault(inputs, key, default) |
Extract integer input with fallback |
sdk.GetBoolDefault(inputs, key, default) |
Extract boolean input with fallback |
sdk.EventSubscriber |
Interface for receiving events (Patterns() + HandleEvent()) |
sdk.Event |
SDK event struct with ID, Type, Source, Metadata, Payload |
Implement sdk.Validator to add custom validation rules that run during awf validate. AWF calls your validator after built-in validation and displays findings alongside built-in errors.
Severity levels:
| Icon | Severity | Constant |
|---|---|---|
✗ |
Error | sdk.SeverityError |
⚠ |
Warning | sdk.SeverityWarning |
ℹ |
Info | sdk.SeverityInfo |
package main
import (
"context"
"github.com/awf-project/cli/pkg/plugin/sdk"
)
type SecurityValidator struct {
sdk.BasePlugin
}
func (v *SecurityValidator) ValidateWorkflow(ctx context.Context, w sdk.WorkflowDefinition) ([]sdk.ValidationIssue, error) {
var issues []sdk.ValidationIssue
if w.Version == "" {
issues = append(issues, sdk.ValidationIssue{
Severity: sdk.SeverityWarning,
Message: "workflow is missing a version field",
})
}
return issues, nil
}
func (v *SecurityValidator) ValidateStep(ctx context.Context, w sdk.WorkflowDefinition, stepName string) ([]sdk.ValidationIssue, error) {
step, ok := w.Steps[stepName]
if !ok {
return nil, nil
}
var issues []sdk.ValidationIssue
if step.Type == "step" && step.Timeout == 0 {
issues = append(issues, sdk.ValidationIssue{
Severity: sdk.SeverityInfo,
Message: "step has no timeout",
Step: stepName,
Field: "timeout",
})
}
return issues, nil
}
func main() {
sdk.Serve(&SecurityValidator{
BasePlugin: sdk.BasePlugin{
PluginName: "awf-plugin-security-validator",
PluginVersion: "1.0.0",
},
})
}Declare the validators capability in plugin.yaml:
capabilities:
- validatorsFlags for awf validate:
| Flag | Default | Description |
|---|---|---|
--skip-plugins |
false | Skip all plugin validators |
--validator-timeout |
5s | Per-plugin timeout (e.g., 10s, 2m) |
Validator crashes are treated as timeouts — AWF logs a warning and continues with remaining validators. Results are deduplicated by (message + step + field).
Implement sdk.StepTypeHandler to register new type: values for workflow steps. AWF calls StepTypes() once at init to cache registrations, then routes any step with a matching type to your plugin.
Automatic namespacing: Plugins declare short step type names (e.g. query). The host automatically prefixes with <manifest-name>. at registration. Users write the qualified name in YAML (e.g. type: database.query where database is the name in plugin.yaml). The plugin receives the short name in ExecuteStep. This follows the same pattern as operation namespacing.
package main
import (
"context"
"fmt"
"github.com/awf-project/cli/pkg/plugin/sdk"
)
type DatabasePlugin struct {
sdk.BasePlugin
}
// StepTypes declares short names — the host auto-prefixes with "awf-plugin-database."
func (p *DatabasePlugin) StepTypes() []sdk.StepTypeInfo {
return []sdk.StepTypeInfo{
{Name: "query", Description: "Execute a SQL query"},
{Name: "migrate", Description: "Run database migrations"},
}
}
// ExecuteStep receives the short name (prefix stripped by host)
func (p *DatabasePlugin) ExecuteStep(ctx context.Context, req sdk.StepExecuteRequest) (sdk.StepExecuteResult, error) {
switch req.StepType {
case "query":
query, _ := req.Config["query"].(string)
// ... execute query
return sdk.StepExecuteResult{
Output: fmt.Sprintf("executed: %s", query),
Data: map[string]any{"rows": 42},
ExitCode: 0,
}, nil
default:
return sdk.StepExecuteResult{ExitCode: 1}, fmt.Errorf("unknown step type: %s", req.StepType)
}
}
func main() {
sdk.Serve(&DatabasePlugin{
BasePlugin: sdk.BasePlugin{
PluginName: "database",
PluginVersion: "1.0.0",
},
})
}Declare the step_types capability in plugin.yaml:
capabilities:
- step_typesStep type name conflicts are resolved by first-registered-wins on the qualified name. AWF logs a warning if two plugins register the same qualified type name.
See Workflow Syntax - Custom Step Types for how to use custom step types in workflows.
Implement sdk.EventSubscriber to react to workflow lifecycle events and events emitted by other plugins. This enables real-time notifications, metrics collection, audit logging, and inter-plugin communication.
Event Subscriber Interface:
type EventSubscriber interface {
Patterns() []string // Event types to subscribe to (glob patterns)
HandleEvent(ctx context.Context, event Event) ([]Event, error) // Handle incoming event
}Available Events:
Plugins can subscribe to core workflow lifecycle events emitted by the AWF ExecutionService:
| Event Type | Description | Metadata |
|---|---|---|
workflow.started |
Workflow execution started | workflow_id, workflow_name |
workflow.completed |
Workflow completed successfully | workflow_id, workflow_name, duration |
workflow.failed |
Workflow failed | workflow_id, workflow_name, error_message |
step.started |
Step execution started | workflow_id, step_name |
step.completed |
Step completed | workflow_id, step_name |
step.failed |
Step failed | workflow_id, step_name, error_message |
step.retrying |
Step retrying after failure | workflow_id, step_name, attempt |
Plugins can also emit custom events that other plugins subscribe to (e.g., deploy.completed, notification.sent).
Event Subscriber Example:
package main
import (
"context"
"log"
"github.com/awf-project/cli/pkg/plugin/sdk"
)
type NotificationPlugin struct {
sdk.BasePlugin
}
// Patterns declares which event types this plugin subscribes to
// Supports glob patterns: workflow.* matches all workflow events
func (p *NotificationPlugin) Patterns() []string {
return []string{"workflow.completed", "workflow.failed"}
}
// HandleEvent is called when a matching event occurs
func (p *NotificationPlugin) HandleEvent(ctx context.Context, event sdk.Event) ([]sdk.Event, error) {
log.Printf("Workflow %s %s\n", event.Metadata["workflow_id"], event.Type)
// Plugins can emit events that other plugins will receive
if event.Type == "workflow.completed" {
return []sdk.Event{
{
Type: "notification.sent",
Source: p.PluginName,
Metadata: map[string]string{
"channel": "slack",
"status": "success",
},
},
}, nil
}
return nil, nil
}
func main() {
sdk.Serve(&NotificationPlugin{
BasePlugin: sdk.BasePlugin{
PluginName: "awf-plugin-notify",
PluginVersion: "1.0.0",
},
})
}Plugin Manifest Declaration:
Declare event subscriptions and emissions in plugin.yaml:
name: awf-plugin-notify
version: 1.0.0
description: Sends notifications on workflow events
awf_version: ">=0.7.0"
capabilities:
- events
events:
subscribe:
- "workflow.*" # Subscribe to all workflow events
- "step.failed" # Subscribe to failed steps
emit:
- "notification.sent" # Emit custom events
- "notification.failed"Pattern Matching:
Event patterns use glob matching with . as segment separator:
| Pattern | Matches | Does NOT Match |
|---|---|---|
workflow.started |
Exact match | workflow.completed |
workflow.* |
workflow.started, workflow.completed, workflow.failed |
workflow.step.started |
step.* |
All step events | workflow.step.completed |
*.* |
All two-segment events | workflow (single segment) |
* |
Single-segment events only | — |
Back-Pressure & Isolation:
- Each plugin receives events on its own buffered channel (256-event capacity)
- Slow plugins don't block event delivery to other plugins
- If a plugin's buffer fills, events are dropped with a warning logged Cycle Detection:
AWF prevents event loops by limiting propagation depth to 3 levels. If Plugin A emits an event that triggers Plugin B, which emits an event triggering Plugin A, propagation stops at depth 3 and a warning is logged.
AWF's MCP proxy (mcp_proxy.plugin_tools in a workflow step) re-exposes a plugin's operations as MCP tools, letting an AI agent invoke them directly during execution. Your plugin doesn't have to opt in or implement a new interface — every operation registered via Operations() is automatically eligible — provided its schema satisfies the constraints below.
The MCP tool schema is derived from your operation's OperationSchema via the MapOperationSchema translator. Only scalar input types are allowed:
OperationSchema.Inputs[].Type |
Eligible? | Notes |
|---|---|---|
string |
✅ | Translates to {"type": "string"} |
integer |
✅ | Translates to {"type": "integer"} |
boolean |
✅ | Translates to {"type": "boolean"} |
array |
❌ | Rejected with USER.MCP_PROXY.UNSUPPORTED_SCHEMA at step startup |
object |
❌ | Rejected with USER.MCP_PROXY.UNSUPPORTED_SCHEMA at step startup |
If an operation needs structured input (a list of items, a nested config), it can still be invoked as a workflow operation: step — but it cannot be exposed to agents via the MCP proxy until the schema is refactored to scalar fields or split into multiple smaller operations.
Two Validation values are forwarded to the JSON Schema format field, which most MCP-aware models honor: "url" → "uri", "email" → "email". Other Validation values are accepted by AWF but not propagated to the MCP tool schema.
The exposed tool name is <plugin>_<operation> (single underscore separator, snake_case) — for example, awf-plugin-time.time becomes the MCP tool awf-plugin-time_time. Pick operation names that read well in this form: create_issue, kubectl_apply, query_db. Dots in operation names are forbidden because the Claude MCP client rejects them; AWF validates this at workflow load time.
The agent sees a description composed from two fields of your OperationSchema:
<Description>. Returns a JSON object with fields: <Outputs joined by ", ">.
Concretely:
| Schema field | Agent-visible result |
|---|---|
Description: "Returns the current UTC time." + Outputs: ["unix", "iso8601", "rfc3339"] |
Returns the current UTC time. Returns a JSON object with fields: unix, iso8601, rfc3339. |
Description: "" + Outputs: ["unix"] |
Operation 'time' from plugin 'awf-plugin-time'. Returns a JSON object with fields: unix. |
Description: "Fetches an issue." + Outputs: [] |
Fetches an issue. |
Practical takeaway for plugin authors who want good agent-tool ergonomics:
- Always populate
Descriptionwith a single sentence stating what the operation does. - Populate
Outputswith the field names the agent will read from the result (e.g.["url", "title", "body"]forgithub.get_issue). Models perform much better at multi-step reasoning when they know the output shape up front.
package main
import (
"context"
"time"
"github.com/awf-project/cli/pkg/plugin/sdk"
)
type TimePlugin struct {
sdk.BasePlugin
}
func (p *TimePlugin) Operations() []string {
return []string{"time"}
}
func (p *TimePlugin) OperationSchema(name string) *sdk.OperationSchema {
if name != "time" {
return nil
}
return &sdk.OperationSchema{
Description: "Returns the current UTC time as Unix epoch seconds and ISO-8601.",
Inputs: map[string]sdk.InputSpec{}, // no inputs
Outputs: []string{"unix", "iso8601"},
}
}
func (p *TimePlugin) HandleOperation(_ context.Context, _ string, _ map[string]any) (*sdk.OperationResult, error) {
now := time.Now().UTC()
return sdk.NewSuccessResult("", map[string]any{
"unix": now.Unix(),
"iso8601": now.Format(time.RFC3339),
}), nil
}
func main() {
sdk.Serve(&TimePlugin{
BasePlugin: sdk.BasePlugin{PluginName: "awf-plugin-time", PluginVersion: "1.0.0"},
})
}Users then expose it to an agent like so:
agent_with_time:
type: agent
provider: claude
prompt: "Use the awf-plugin-time_time tool to read the current UTC time, then ..."
mcp_proxy:
enable: true
intercept_builtins: false
plugin_tools:
- plugin: awf-plugin-time
expose:
- time
options:
dangerously_skip_permissions: trueWhen a workflow references plugin_tools: [{plugin: P, expose: [op]}], AWF emits these errors at awf validate / awf run time, before the agent ever starts:
| Error code | Cause |
|---|---|
USER.MCP_PROXY.UNKNOWN_PLUGIN |
Plugin P is not installed or not enabled |
USER.MCP_PROXY.UNKNOWN_OPERATION |
Operation op is not in P.Operations() |
USER.MCP_PROXY.UNSUPPORTED_SCHEMA |
One of op's Inputs uses array or object |
USER.MCP_PROXY.NAME_COLLISION |
Two expose: entries (across plugins or with a built-in tool) resolve to the same MCP tool name |
Test these paths in your plugin's CI by running a workflow that exposes each operation under plugin_tools against a Claude or Gemini provider. The repo includes reference workflows at .awf/workflows/test-mcp-proxy-{claude,gemini,opencode}-plugin-tools.yaml that you can adapt for your plugin.
The examples/plugins/awf-plugin-echo/ directory contains a complete working plugin that echoes its input text. Use it as a starting point:
cd examples/plugins/awf-plugin-echo
make install # Build and install to ~/.local/share/awf/plugins/
awf plugin enable awf-plugin-echoUse it in a workflow:
echo_step:
type: operation
operation: awf-plugin-echo.echo
inputs:
text: "Hello from plugin!"
prefix: ">>>"
on_success: doneAWF installs plugins from GitHub Releases. Plugin authors must publish .tar.gz archives with a specific naming convention so AWF can resolve the correct asset for each platform.
Assets must follow this pattern:
<plugin-name>_<version>_<os>_<arch>.tar.gz
| Component | Values |
|---|---|
plugin-name |
Plugin binary name (e.g. awf-plugin-jira) |
version |
Semantic version without v prefix (e.g. 1.2.0) |
os |
linux, darwin, windows |
arch |
amd64, arm64 |
Example release assets:
awf-plugin-jira_1.2.0_linux_amd64.tar.gz
awf-plugin-jira_1.2.0_linux_arm64.tar.gz
awf-plugin-jira_1.2.0_darwin_amd64.tar.gz
awf-plugin-jira_1.2.0_darwin_arm64.tar.gz
Each archive must contain:
- The plugin binary (
awf-plugin-<name>) - A
plugin.yamlmanifest
A checksums.txt file (SHA-256) must be included as a separate release asset. AWF verifies the checksum before installation.
Use GoReleaser to automate plugin releases. Add a .goreleaser.yml to your plugin repository:
project_name: awf-plugin-myplugin
builds:
- main: .
binary: awf-plugin-myplugin
goos:
- linux
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}}
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- plugin.yaml
checksum:
name_template: checksums.txt
algorithm: sha256
release:
github:
owner: your-org
name: awf-plugin-mypluginAll archives must use .tar.gz format. This differs from AWF's own releases which use .zip on macOS.
AWF uses authentication for GitHub API requests in the following order:
GITHUB_TOKENenvironment variable (if set)gh auth tokenoutput (ifghCLI is installed and authenticated)- Unauthenticated requests (subject to lower rate limits)
Set GITHUB_TOKEN for CI environments or to avoid rate limiting:
export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
awf plugin install myorg/awf-plugin-jiraWhen the GitHub API rate limit is exceeded, AWF detects the X-RateLimit-Remaining: 0 header and returns an actionable error message suggesting authentication.
Error: plugin "awf-plugin-foo" not found
Check:
- Plugin directory exists in
~/.local/share/awf/plugins/ - Directory name matches plugin name
plugin.yamlmanifest is present and valid
Error: failed to load plugin: exec format error
The plugin binary is not compatible with your system. Rebuild for your platform.
Error: plugin requires awf >=0.5.0, current version is 0.4.0
Update AWF or use a compatible plugin version.
- Plugin Events - Event subscriptions, inter-plugin communication, and pattern matching
- MCP Proxy - Exposing plugin operations as MCP tools for AI agents
- Commands - CLI command reference
- Workflow Syntax - Operation usage in workflows
- Architecture - Plugin system internals