Extensions

Extend phi with Go — tools, slash commands, and lifecycle event handlers loaded with yaegi.

Extensions are Go source files loaded with yaegi (the Go interpreter). They replace the former shell plugin.json hooks system.

Security: extensions run with your full process permissions. Only install from sources you trust.

Locations

Location Scope
~/.phi/extensions/*.go Global (all projects)
~/.phi/extensions/*/index.go Global (subdirectory)
<cwd>/.phi/extensions/*.go Project-local
<cwd>/.phi/extensions/*/index.go Project-local (subdirectory)

Same extension id (file stem or directory name): project replaces user. Disable all with PHI_EXTENSIONS=off.

Quick start

Create ~/.phi/extensions/hello.go:

package main

import (
	"context"
	"encoding/json"

	"github.com/pulseaiclub/phi/ext"
)

// Extension registers a minimal greet tool and /hello command.
func Extension(phi *ext.API) {
	phi.RegisterTool(ext.ToolDef{
		Name:        "greet",
		Label:       "Greet",
		Description: "Greet someone by name",
		Parameters: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"name": map[string]any{"type": "string", "description": "Name to greet"},
			},
			"required": []any{"name"},
		},
		Execute: func(ctx context.Context, args json.RawMessage) (ext.ToolResult, error) {
			var in struct {
				Name string `json:"name"`
			}
			_ = json.Unmarshal(args, &in)
			msg := "Hello, " + in.Name + "!"
			return ext.ToolResult{Content: msg, Output: msg}, nil
		},
	})

	phi.On(ext.EventToolCall, func(ev ext.ToolCallEvent, ctx *ext.Context) *ext.ToolCallResult {
		// return &ext.ToolCallResult{Block: true, Reason: "..."} to deny
		return nil
	})

	phi.RegisterCommand("hello", ext.CommandDef{
		Description: "Say hello via toast",
		Handler: func(args string, ctx *ext.Context) error {
			name := args
			if name == "" {
				name = "world"
			}
			if ctx.UI != nil {
				ctx.UI.Notify("Hello "+name+"!", "info")
			}
			return nil
		},
	})
}

Entry point: export func Extension(phi *ext.API) in package main. The phi repo ships samples under .phi/extensions/ (hello.go, guard_bash.go).

Reload in TUI: Ctrl+K → extensions → reload. List: extensions → list.

Events

Event When Result
tool_call Before permission Gate {Block, Reason, Input, Context}
tool_result After tool run {Content, Context, Stop, Reason}
tool_execution_start / tool_execution_end Around tool run notify
session_start / session_shutdown / session_before_switch Session lifecycle before_switch may {Cancel}
before_agent_start After user submit {SystemPromptAppend}
agent_start / agent_end Around Loop notify
turn_start / turn_end Per LLM round notify

Tool loop order remains ExtensionPre → Gate/Ask → Run → ExtensionPost (does not bypass the permission gate).

Model-only notes from handlers are wrapped in <ext_context>…</ext_context> on the tool message (TUI Detail/Output unchanged).

API

Import github.com/pulseaiclub/phi/ext — symbols are injected into yaegi, so no extra module download is needed for the ext package itself.

Method Purpose
On(event, handler) Subscribe
RegisterTool(ToolDef) LLM-callable tool
RegisterCommand(name, CommandDef) Slash command (cannot override builtins)
GetActiveTools / SetActiveTools / GetAllTools Tool set (after host bind)
Exec / SendUserMessage Host actions (after bind)

ctx.UI: Notify, Confirm, SetStatus (TUI). Headless phi run may have no UI.

Migration from hooks

Shell plugin.json hooks under ~/.phi/hooks / .phi/hooks are removed. Rewrite policy as Go:

Old hook Extension
PreToolUse deny On(EventToolCall, …)Block: true
PostToolUse context On(EventToolResult, …)Context
Command slash RegisterCommand
SessionStart / Shutdown / BeforeSwitch matching session_* events

Layout

Path Role
ext/ Public types + API for authors
internal/extension/ Discover, yaegi loader, Runner
.phi/extensions/ Project samples (hello.go, guard_bash.go)