starling

Quickstart

From zero to a running agent with a durable event log and a verified replay.

Build a single-tool agent, persist it to SQLite, verify replay.

Install

terminal
go get github.com/jerkeyray/starling

Go 1.26+. SQLite is pure-Go via modernc.org/sqlite; no CGo.

Hello agent

main.go
package main

import (
    "context"
    "fmt"
    "os"
    "time"

    starling "github.com/jerkeyray/starling"
    "github.com/jerkeyray/starling/eventlog"
    "github.com/jerkeyray/starling/provider/openai"
    "github.com/jerkeyray/starling/step"
    "github.com/jerkeyray/starling/tool"
)

type clockOut struct {
    UTC string `json:"utc"`
}

func main() {
    prov, err := openai.New(openai.WithAPIKey(os.Getenv("OPENAI_API_KEY")))
    if err != nil {
        panic(err)
    }

    // step.Now records the timestamp on the live run and returns the
    // recorded value on replay, so the tool stays deterministic.
    clock := tool.Typed(
        "current_time",
        "Return the current UTC time in RFC3339.",
        func(ctx context.Context, _ struct{}) (clockOut, error) {
            return clockOut{UTC: step.Now(ctx).UTC().Format(time.RFC3339)}, nil
        },
    )

    a := &starling.Agent{
        Provider: prov,
        Tools:    []tool.Tool{clock},
        Log:      eventlog.NewInMemory(),
        Config:   starling.Config{Model: "gpt-4o-mini", MaxTurns: 4},
    }

    res, err := a.Run(context.Background(), "What is the current UTC time?")
    if err != nil {
        panic(err)
    }
    fmt.Println(res.FinalText)
}

tool.Typed derives the JSON schema from the input type. The agent records ToolCallScheduled + ToolCallCompleted around every dispatch.

Persist the run

Swap the in-memory log for SQLite to keep events on disk:

main.go
log, err := eventlog.NewSQLite("starling.db")
if err != nil {
    panic(err)
}
defer log.Close()

a := &starling.Agent{
    Provider: prov,
    Tools:    []tool.Tool{clock},
    Log:      log,
    Config:   starling.Config{Model: "gpt-4o-mini", MaxTurns: 4},
}

NewSQLite opens in WAL mode and auto-migrates. One writer, many readers.

Replay the run

Once a run is persisted you can re-execute it against the same agent and verify every emitted event matches the recording byte-for-byte:

main.go
import "errors"

if err := starling.Replay(ctx, log, runID, a); err != nil {
    if errors.Is(err, starling.ErrNonDeterminism) {
        // A tool output, prompt, or model changed since the original run.
        // err carries a *replay.Divergence with seq + class fields.
    }
    panic(err)
}

Replay reads recorded LLM and tool outputs from the log and never re-invokes the provider. If your code path hits step.Now, step.Random, or step.SideEffect, the recorded values are returned instead. Anything else is non-deterministic and should be moved behind a step helper.

Inspect a run

terminal
go run github.com/jerkeyray/starling/cmd/starling-inspect starling.db

Embedded read-only web UI: runs list, timeline, payload detail, replay controls, divergence rendering. Dark by default, click-to-pick diff page, syntax-highlighted JSON. Full tour in Inspector.

Where to next

On this page