Extensibility
sharur supports three extension points:
- Skills — reusable prompt templates invoked with
/skill-name - Prompts — system prompt injection via YAML files
- Extensions — in-process Go, out-of-process Python, or gRPC plugins
sharur supports three extension points:
/skill-nameSkills are Markdown files that provide sharur with specialized, reusable instructions for specific tasks. When a skill is invoked, its content is sent as a user message to the agent along with any arguments you provide.
When sharur starts, it scans the skill directories and adds a list of available skills to the system prompt. The agent knows which skills exist and their descriptions. You can explicitly invoke a skill with /skill:<name> from the TUI, or the agent may choose to invoke one automatically via the read tool or a specialized skill tool call.
When you invoke a skill via /skill:<name>, it is executed as a skill tool, which loads the content and sends it to the agent:
sharur searches for skills in these locations (in order):
| Path | Scope |
|---|---|
~/.sharur/skills/ | Global — available in all projects |
.sharur/skills/ (project root) | Project-specific skills |
Skills with the same name in a project directory override global ones.
.md fileCreate a .md file directly in a skills directory. The filename (without extension) becomes the skill name.
Invoke with:
SKILL.mdCreate a directory containing a SKILL.md file. The directory name becomes the skill name. This format lets you include supporting files (examples, templates) alongside the skill.
Invoke with:
Note: When a
SKILL.mdis found in a directory, subdirectories are not scanned further. This lets you bundle reference files with your skill.
Both formats support optional YAML frontmatter to provide metadata:
Frontmatter fields:
| Field | Description |
|---|---|
name | Override the skill name (defaults to filename/directory name) |
description | A short description shown to the agent in the system prompt |
.sharur/skills/code-review.md
Invoke:
Or attach a file reference:
~/.sharur/skills/explain.md
read tool on supporting files./skill:<name> in the TUI. The skill’s content and its effect on the conversation will be visible in the tool output cards..sharur/skills/ to override the global version for a specific project.Prompt templates are reusable text snippets that expand directly into the TUI input editor. Unlike skills (which are sent to the agent immediately), prompt templates let you pre-fill the editor so you can review, edit, or complete the text before sending.
When you type /prompt:<name> and press Enter, the template content is loaded into the editor input. You can then modify it, add context, attach files with @, and send it normally. This is useful for long, structured prompts you use frequently.
sharur searches these locations (in order):
| Path | Scope |
|---|---|
~/.sharur/prompts/ | Global — available in all projects |
.sharur/prompts/ (project root) | Project-specific templates |
A prompt template is any .md file in a prompts directory. The filename (without extension) is the template name.
Invoke with:
The entire file content becomes the template text:
Add optional YAML frontmatter for metadata:
Frontmatter fields:
| Field | Description |
|---|---|
description | Short description shown in the /prompt: picker |
argument-hint | Hint shown in autocomplete describing expected arguments |
Templates support positional argument placeholders: $1, $2, etc.
When you invoke a template via the slash command handler (not the interactive TUI), arguments after the template name are substituted. To mitigate prompt injection, sharur automatically wraps these arguments in <untrusted_input> tags. In the TUI, the template expands as-is and you fill in the values manually.
.sharur/prompts/pr-description.md
Invoke:
Then paste or attach the diff before sending.
.sharur/prompts/adr.md
Invoke:
~/.sharur/prompts/commit.md
Invoke:
.sharur/prompts/explain-for-review.md
$1, $2 placeholders for dynamic parts you’ll always fill in differently. Leave static boilerplate as literal text.@ file attachments. Type /prompt:code-review then add @src/myfile.go before pressing Enter to attach a file..sharur/prompts/ with the same name as a global template takes priority for that project./prompt:refactor, /prompt:adr, etc. (name is the filename, not the full path).Extensions let you add new behaviors to sharur beyond what’s possible with skills and prompt templates. They can observe and modify every stage of the agent loop — from the raw user input through each LLM turn and tool call to compaction and session teardown. Extensions run as separate processes and communicate with sharur via gRPC.
| Type | Language | Use Case |
|---|---|---|
| Go binary | Go | High-performance tools, direct filesystem access |
| Python script | Python | Data processing, ML integrations, API calls |
| Any executable | Any | Shell scripts, compiled binaries from any language |
All extension types use the same gRPC protocol. The loader treats .py files specially (runs them with the configured Python interpreter), and everything else is executed directly as a binary.
Extensions are loaded from directories listed in your config under extensions:
Or globally in ~/.sharur/config.json.
Place your extension binary or script in the configured directory. sharur will automatically discover and launch it on startup.
You can also load a specific extension at runtime with the --extension flag:
Every Go extension implements the extensions.Plugin interface from github.com/goppydae/sharur/extensions. Embed extensions.NoopPlugin and override only the hooks you need.
| Method | When called | Purpose |
|---|---|---|
Name() | On load | Returns the extension’s identifier string |
Tools() | On load | Returns tool definitions the agent can call |
ExecuteTool() | On tool call | Executes a tool registered by this extension |
| Method | When called | Purpose |
|---|---|---|
SessionStart(ctx, sessionID, reason) | Session attached or first prompt | Open connections, initialize per-session state |
SessionEnd(ctx, sessionID, reason) | Session reset | Flush buffers, close connections |
reason is "new" for a fresh session and "resume" for one loaded from disk.
| Method | When called | Purpose |
|---|---|---|
AgentStart(ctx) | User prompt received, loop begins | Per-prompt setup, logging |
AgentEnd(ctx) | Agent loop completes | Per-prompt teardown, emit metrics |
TurnStart(ctx) | Start of each LLM request turn | Per-turn timing |
TurnEnd(ctx) | After each turn’s tool calls finish | Per-turn cleanup |
| Method | When called | Can modify | Purpose |
|---|---|---|---|
ModifyInput(ctx, text) | Before user text hits the transcript | Yes — transform or consume | Pre-process input, implement shortcuts |
ModifySystemPrompt(prompt) | Before each LLM request | Yes — returns new prompt | Inject dynamic context into the system prompt |
BeforePrompt(ctx, state) | Before each LLM request | Yes — returns new state | Change model, provider, or thinking level |
ModifyContext(ctx, messagesJSON) | Before each LLM request is built | Yes — returns new JSON | Filter or inject messages sent to the LLM (transcript unchanged) |
BeforeProviderRequest(ctx, requestJSON) | Just before the request is sent | Yes — returns new JSON | Modify temperature, max tokens, tools list |
AfterProviderResponse(ctx, content, numToolCalls) | After LLM stream consumed | No | Observe response text and tool call count |
BeforeToolCall(ctx, call, args) | Before each tool execution | Yes — can intercept | Block or replace tool execution |
AfterToolCall(ctx, call, result) | After each tool execution | Yes — returns new result | Observe or modify tool results |
BeforeCompact(ctx, prep) | Before LLM-based summarization | Yes — can skip | Provide a custom compaction summary |
AfterCompact(ctx, freedTokens) | After compaction completes | No | Observe freed token count |
Key behaviors:
ModifyInput returns agent.InputResult. Set Action to "continue" (pass through unchanged), "transform" (use the Text field instead), or "handled" (consume the message entirely — it is not appended to the transcript and the agent does not run).ModifyContext and BeforeProviderRequest work with JSON strings at the gRPC boundary. The GRPCClient marshals/unmarshals the Go structs automatically.BeforeCompact returns "" (empty) to let the default LLM summarization run, or a non-empty summary string to provide your own and skip the LLM call. The prep argument includes the message count, estimated token count, and the previous summary (if any).BeforeToolCall returns (ToolResult, true) to intercept (the tool does not execute), or (ToolResult{}, false) to allow normal execution.Build and auto-discover:
ModifyInput runs before the user text is added to the transcript. Return "handled" to consume shortcuts silently, or "transform" to rewrite the text:
Return a non-nil *agent.CompactionResult from BeforeCompact to supply your own summary and bypass the default LLM-based summarization:
Extensions can contribute tools the agent calls just like built-in tools:
BeforeToolCall lets you block or replace any built-in tool call:
See examples/sandbox/ for a complete standalone implementation.
flowchart TD
Start["shr startup"] --> Scan["Scan extension directories"]
Scan --> Launch["Launch subprocess
SHARUR_SOCKET_PATH=..."]
Launch --> Socket["Wait for socket · dial gRPC"]
Socket --> Init["Name() · Tools()"]
Init --> SS["SessionStart(sessionID, reason)
on new session or resume"]
SS --> MI["ModifyInput(text)"]
MI --> AS["AgentStart()"]
subgraph turn ["Per LLM turn (repeats until no tool calls)"]
direction TB
T1["BeforePrompt() · ModifySystemPrompt()
ModifyContext() · BeforeProviderRequest()"]
T2[/"LLM streams"/]
T3["AfterProviderResponse() · TurnStart()"]
subgraph toolloop ["Per tool call"]
BTC["BeforeToolCall()"] --> Intercept{"intercept?"}
Intercept -->|yes| CustomResult["return custom ToolResult"]
Intercept -->|no| Exec["execTool() · AfterToolCall()"]
end
TE["TurnEnd()"]
T1 --> T2 --> T3 --> toolloop --> TE
end
AS --> turn
turn --> AE["AgentEnd()"]
subgraph compact ["On compaction (auto or /compact)"]
direction TB
BC["BeforeCompact(prep)"] --> CustomSummary{"return non-nil?"}
CustomSummary -->|yes| SkipLLM["skip LLM summarization"]
CustomSummary -->|no| LLMSum["LLM summarizes"]
SkipLLM --> AC["AfterCompact(freedTokens)"]
LLMSum --> AC
end
AE --> SE["SessionEnd(sessionID, reason)
on session reset"]
SE --> Shutdown["shr shutdown · kill subprocess"]If your extension is written in Go and you control the build, you can implement agent.Extension directly via the SDK and register it without the gRPC overhead:
Pass the extension via ag.SetExtensions() from the SDK or directly in cmd/shr.
sharur — the loader catches errors and logs them.BeforePrompt and ModifySystemPrompt fast. They run before every single LLM call. Cache data when possible; avoid blocking network calls.ModifyContext does not affect the stored transcript. Changes to the message slice are only visible to the LLM for that turn.InputHandled stops all further processing. No agent turn is started, no message is appended to the transcript.BeforeCompact fires before the LLM call. Return nil to let the default summarizer run. Return a *CompactionResult to supply your own summary — useful for using a cheaper model or domain-specific logic.Python extensions use the same gRPC protocol as Go extensions. The loader detects .py files and runs them with the configured Python interpreter, passing SHARUR_SOCKET_PATH as an environment variable. The extension is expected to listen on that Unix socket.
This deposits extension_pb2.py and extension_pb2_grpc.py alongside your script.
Place the script in your extensions directory. sharur runs it as python ticket_context.py on startup.
Implement any subset of the ExtensionServicer methods. Unimplemented methods should return a sensible empty response (see the template above). The full list mirrors the Go plugin interface — see Go Extensions for hook semantics.
| RPC | Purpose |
|---|---|
Name | Return extension identifier |
Tools | Return tool definitions |
ExecuteTool | Execute a registered tool |
SessionStart / SessionEnd | Session lifecycle |
AgentStart / AgentEnd | Per-prompt lifecycle |
TurnStart / TurnEnd | Per-LLM-turn lifecycle |
ModifyInput | Transform or consume user input |
ModifySystemPrompt | Augment the system prompt |
BeforePrompt | Mutate model/provider/thinking |
ModifyContext | Filter or inject LLM-bound messages |
BeforeProviderRequest | Modify the raw completion request |
AfterProviderResponse | Observe LLM output |
BeforeToolCall | Intercept or block tool calls |
AfterToolCall | Observe or modify tool results |
BeforeCompact / AfterCompact | Compaction lifecycle |
print() goes to stdout, which is not read by the host. Use sys.stderr.write() or logging for debugging output.sys.path before importing them.grpc.server with ThreadPoolExecutor handles concurrent RPC calls. If you maintain per-session state, use a lock or session-keyed dict.gRPC extensions run as separate processes. sharur manages their lifecycle: launching the binary, passing the socket path, waiting for readiness, dialing, and killing on shutdown. The extension communicates entirely over a Unix Domain Socket using the generated proto stubs in extensions/proto/extension.proto.
sequenceDiagram
participant Loader as shr Loader
participant Ext as Extension process
participant Client as gRPC client
Loader->>Ext: exec binary/script
note over Ext: env: SHARUR_SOCKET_PATH=/tmp/...sock
Ext->>Ext: net.Listen("unix", socketPath)
note over Ext: signals readiness by listening
Loader->>Loader: poll for socket file
Loader->>Client: dial gRPC over Unix socket
Client->>Ext: Name()
Ext-->>Client: "my-extension"
Client->>Ext: Tools()
Ext-->>Client: [ToolDefinition, ...]
note over Loader,Ext: extension registered — hooks active for all sessionsThe extension must call net.Listen("unix", os.Getenv("SHARUR_SOCKET_PATH")) and start serving before shr times out.
Import github.com/goppydae/sharur/extensions — no internal packages needed.
extensions.Serve handles the socket path, gRPC server setup, and graceful shutdown. extensions.NoopPlugin provides no-op defaults for every method.
Build and place the binary in a configured extensions directory:
Or load at runtime:
All hooks map 1:1 to agent.Extension. See Go Extensions for full hook semantics and examples.
Load-time:
| Method | Called | Purpose |
|---|---|---|
Name() | Once on connect | Extension identifier |
Tools() | Once on connect | Contribute tools to the agent |
ExecuteTool() | Per tool call | Execute a registered tool |
Session lifecycle:
| Method | Called | Purpose |
|---|---|---|
SessionStart(ctx, sessionID, reason) | New or resumed session | Open connections, init per-session state |
SessionEnd(ctx, sessionID, reason) | Session reset | Flush, close connections |
reason is "new" or "resume".
The extension service is defined in extensions/proto/extension.proto. Generated Go stubs are in extensions/gen/. Regenerate with mage generate.
Python stubs can be generated with:
Tool definitions returned by Tools() have an IsReadOnly bool field. Set it to true for tools that are safe in dry-run mode. The GRPCClient propagates this to the internal RemoteTool.IsReadOnly() so dry-run and sandbox extensions honour it correctly.
log.Println or fmt.Fprintln(os.Stderr, ...) for debug output.shr — the loader catches errors and logs them.extensions.Serve (or your own net.Listen + grpc.Serve) is called promptly in main().SHARUR_SOCKET_PATH=/tmp/test.sock and run your extension binary directly; then grpcurl the socket to verify RPCs before integrating with shr.