SDK

The github.com/goppydae/sharur/sdk package lets you embed a sharur agent in any Go program.

import "github.com/goppydae/sharur/sdk"

See the sub-pages for a quickstart, custom tool implementations, the EventBus API, and in-process extensions.

Subsections of SDK

Quickstart

Import github.com/goppydae/sharur/sdk to embed an agent in any Go program.

import "github.com/goppydae/sharur/sdk"

ag, err := sdk.NewAgent(sdk.Config{
    Provider: "ollama",
    Model:    "llama3.2",
    Tools:    sdk.DefaultTools(),
})
if err != nil {
    panic(err)
}

ag.Subscribe(func(e sdk.Event) {
    if e.Type == sdk.EventTextDelta {
        fmt.Print(e.Content)
    }
})

ag.Prompt(context.Background(), "List the Go files in this directory")
<-ag.Idle()

Config Fields

type Config struct {
    Provider    string        // "ollama", "openai", "anthropic", "llamacpp", "google"
    Model       string        // model name or "provider/model"
    APIKey      string        // optional; env vars take priority
    BaseURL     string        // optional provider endpoint override
    Tools       []sdk.Tool    // sdk.DefaultTools() or custom list
    Extensions  []sdk.Extension
    SystemPrompt string
    ThinkingLevel sdk.ThinkingLevel
    SessionDir  string        // where to persist sessions
    DryRun      bool
}

Core API

CallDescription
sdk.NewAgent(cfg)Create and initialize an agent
ag.Subscribe(fn)Register an event handler; called for every emitted event
ag.Prompt(ctx, text)Send a user message and start the agent loop
ag.Idle()Returns a channel that closes when the agent reaches Idle state
ag.Steer(ctx, text)Inject a steering message into the running turn
ag.FollowUp(ctx, text)Queue a message to process after the current turn
ag.Abort(ctx)Cancel the current running turn
ag.SetExtensions(exts)Replace the extension list (takes effect on next prompt)

Event Types

Subscribe to events by checking e.Type:

Event typePayload fieldDescription
EventAgentStartAgent loop started
EventAgentEndAgent loop completed
EventTurnStartLLM turn started
EventTurnEndLLM turn completed
EventTextDeltae.ContentIncremental response text
EventThinkingDeltae.ContentIncremental thinking text
EventToolCalle.ToolCallTool invocation started
EventToolDeltae.ContentStreaming tool output
EventToolOutpute.ToolOutputFinal tool result

Minimal Example (no tools, no session)

ag, _ := sdk.NewAgent(sdk.Config{
    Provider: "anthropic",
    Model:    "claude-sonnet-4-6",
    APIKey:   os.Getenv("ANTHROPIC_API_KEY"),
})

var buf strings.Builder
ag.Subscribe(func(e sdk.Event) {
    if e.Type == sdk.EventTextDelta {
        buf.WriteString(e.Content)
    }
})

ag.Prompt(context.Background(), "What is 2+2?")
<-ag.Idle()
fmt.Println(buf.String())

Custom Tools

Built-in Tools

Pass sdk.DefaultTools() in sdk.Config.Tools to get the full set of built-in tools:

ToolDescription
readRead file contents with offset/limit support
writeCreate or overwrite files
editSearch-and-replace edits within files
bashExecute shell commands
grepSearch file contents via regex
lsList directory contents
findLocate files using glob patterns

bash, write, and edit are destructive. In --dry-run mode they preview what they would do without executing.


Tool Interface

Implement sdk.Tool to create a custom tool:

type Tool interface {
    Name() string
    Description() string
    Schema() json.RawMessage       // JSON Schema for the input parameters
    Execute(ctx context.Context, args json.RawMessage, update ToolUpdate) (*ToolResult, error)
    IsReadOnly() bool              // if true, tool is allowed in dry-run mode
}

ToolUpdate is a callback for streaming partial output while the tool runs:

type ToolUpdate func(content string)

Example: Custom Tool

type CountLinesTool struct{}

func (t *CountLinesTool) Name() string { return "count_lines" }
func (t *CountLinesTool) Description() string {
    return "Count the number of lines in a file"
}
func (t *CountLinesTool) Schema() json.RawMessage {
    return json.RawMessage(`{
        "type": "object",
        "properties": {
            "path": {"type": "string", "description": "File path to count lines in"}
        },
        "required": ["path"]
    }`)
}
func (t *CountLinesTool) IsReadOnly() bool { return true }

func (t *CountLinesTool) Execute(ctx context.Context, args json.RawMessage, update sdk.ToolUpdate) (*sdk.ToolResult, error) {
    var input struct {
        Path string `json:"path"`
    }
    if err := json.Unmarshal(args, &input); err != nil {
        return nil, err
    }
    data, err := os.ReadFile(input.Path)
    if err != nil {
        return &sdk.ToolResult{Content: err.Error(), IsError: true}, nil
    }
    n := strings.Count(string(data), "\n") + 1
    return &sdk.ToolResult{Content: fmt.Sprintf("%d lines", n)}, nil
}

Register alongside the built-in tools:

ag, _ := sdk.NewAgent(sdk.Config{
    Provider: "ollama",
    Model:    "llama3.2",
    Tools:    append(sdk.DefaultTools(), &CountLinesTool{}),
})

Selective Tools

Pass only the tools you want rather than the full default set:

tools := sdk.ToolsFor("read", "grep", "ls")   // subset by name

Or build the list manually to include only read-only tools for a sandboxed agent.

Events

The agent communicates state transitions via an event bus. Every meaningful action emits an sdk.Event to all registered subscribers.


Subscribing

ag.Subscribe(func(e sdk.Event) {
    switch e.Type {
    case sdk.EventTextDelta:
        fmt.Print(e.Content)
    case sdk.EventToolCall:
        fmt.Printf("[tool: %s]\n", e.ToolCall.Name)
    case sdk.EventAgentEnd:
        fmt.Println("\ndone")
    }
})

Multiple subscribers are allowed. Each runs in its own goroutine. The EventBus is non-blocking — Publish enqueues to a 4096-item buffered channel per subscriber and returns immediately, so slow subscribers drop events rather than stalling the agent loop.


Event Reference

Type constantPayloadFired when
EventAgentStartPrompt() called, agent loop begins
EventAgentEndAgent loop completes (all turns done)
EventTurnStartAn LLM request turn begins
EventTurnEndA turn’s tool calls finish
EventMessageStartLLM starts streaming a response
EventMessageEndLLM response stream complete
EventTextDeltae.Content stringIncremental response text chunk
EventThinkingDeltae.Content stringIncremental extended-thinking chunk
EventToolCalle.ToolCallTool invocation requested by LLM
EventToolDeltae.Content stringStreaming partial output from a running tool
EventToolOutpute.ToolOutputFinal tool result (success or error)

Event Flow Per Prompt

EventAgentStart
  EventTurnStart
    EventMessageStart
      EventTextDelta*
      EventThinkingDelta*
      EventToolCall*
    EventMessageEnd
    [per tool call]
      EventToolDelta*
      EventToolOutput
  EventTurnEnd
  [repeat if tool calls triggered another turn]
EventAgentEnd

Agent State Machine

The agent transitions through explicit states visible via EventAgentStart/EventAgentEnd and the ag.Idle() channel:

Idle → Thinking → Executing → Idle
           ↓
       Compacting → Idle
           ↓
         Aborting → Idle

ag.Idle() returns a channel that closes when the agent returns to Idle. Use it to block until a prompt completes:

ag.Prompt(ctx, "Refactor main.go")
<-ag.Idle()
// agent is idle, safe to call Prompt again

In-Process Extensions

If your extension is written in Go and you control the build, you can implement sdk.Extension (an alias of agent.Extension) directly — no gRPC, no subprocess, no socket. This is the lowest-overhead extension path.


Attaching Extensions

type loggingExt struct {
    sdk.NoopExtension
}

func (e *loggingExt) AgentStart(ctx context.Context) { log.Println("agent started") }
func (e *loggingExt) AgentEnd(ctx context.Context)   { log.Println("agent finished") }
func (e *loggingExt) ModifyInput(ctx context.Context, text string) sdk.InputResult {
    if text == "quit" {
        return sdk.InputResult{Action: sdk.InputHandled}
    }
    return sdk.InputResult{Action: sdk.InputContinue}
}

ag.SetExtensions([]sdk.Extension{
    &loggingExt{NoopExtension: sdk.NoopExtension{NameStr: "logger"}},
})

sdk.NoopExtension provides no-op defaults for every method. Embed it and override only what you need.


Extension Interface

type Extension interface {
    Name() string
    Tools() []Tool

    SessionStart(ctx context.Context, sessionID string, reason SessionStartReason)
    SessionEnd(ctx context.Context, sessionID string, reason SessionEndReason)

    AgentStart(ctx context.Context)
    AgentEnd(ctx context.Context)
    TurnStart(ctx context.Context)
    TurnEnd(ctx context.Context)

    ModifyInput(ctx context.Context, text string) InputResult
    ModifySystemPrompt(prompt string) string
    BeforePrompt(ctx context.Context, state *AgentState) *AgentState
    ModifyContext(ctx context.Context, messages []Message) []Message
    BeforeProviderRequest(ctx context.Context, req *CompletionRequest) *CompletionRequest
    AfterProviderResponse(ctx context.Context, content string, numToolCalls int)
    BeforeToolCall(ctx context.Context, call *ToolCall, args json.RawMessage) (*ToolResult, bool)
    AfterToolCall(ctx context.Context, call *ToolCall, result *ToolResult) *ToolResult
    BeforeCompact(ctx context.Context, prep CompactionPrep) *CompactionResult
    AfterCompact(ctx context.Context, freedTokens int)
}

All types are re-exported from sdk so callers only need to import github.com/goppydae/sharur/sdk.


Key Hook Behaviours

ModifyInput — runs before the user text is added to the transcript. Return an InputResult with:

  • sdk.InputContinue — pass through unchanged
  • sdk.InputTransform — replace with result.Text
  • sdk.InputHandled — consume entirely; no agent turn is started and nothing is appended to the transcript

ModifyContext — receives and returns the message slice that will be sent to the LLM. Changes do not affect the stored session transcript — they are ephemeral per-turn.

BeforeToolCall — return (result, true) to intercept and block the tool; return (nil, false) to allow normal execution.

BeforeCompact — return nil to let the default LLM summarization run, or a *CompactionResult to supply your own summary and skip the LLM call.


Example: System Prompt Injection

type gitContextExt struct {
    sdk.NoopExtension
}

func (e *gitContextExt) ModifySystemPrompt(prompt string) string {
    branch, _ := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
    return prompt + "\n\nCurrent git branch: " + strings.TrimSpace(string(branch))
}

Example: Tool Interception

type sandboxExt struct {
    sdk.NoopExtension
    allowedDir string
}

func (e *sandboxExt) BeforeToolCall(_ context.Context, call *sdk.ToolCall, args json.RawMessage) (*sdk.ToolResult, bool) {
    var input struct{ Path string `json:"path"` }
    _ = json.Unmarshal(args, &input)
    if input.Path != "" && !strings.HasPrefix(input.Path, e.allowedDir) {
        return &sdk.ToolResult{
            Content: fmt.Sprintf("blocked: %s is outside %s", input.Path, e.allowedDir),
            IsError: true,
        }, true
    }
    return nil, false
}