minigraph

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: MIT Imports: 7 Imported by: 0

README

MiniGraph

LangGraph for Go, 1.2% of the code. 420 lines — with the comments in. Zero dependencies. You can read all of it.

Go Reference CI Go Report Card


The take. Go is the best language for building web servers and agents — better than Python or JavaScript — because of what it's always been: compiled, lean, simple. One static binary, no runtime zoo, code that still reads clearly at 3 a.m. And agents don't need a fancy framework. One small graph engine covers lean agents and dynamic workflows. MiniGraph is that engine — nodes over typed state, cyclic edges, and nothing you didn't ask for.

Sixty seconds

An agent is a graph of nodes transforming a shared, typed state, with edges that are static or decided by the state at runtime. Cycles are first-class — the entire difference between a flowchart and an agent that loops until it's done.

app, _ := minigraph.New[State]().
    AddNode("agent", callModel).
    AddNode("tools", runTools).
    AddEdge(minigraph.Start, "agent").
    AddRouter("agent", func(ctx context.Context, s State) (string, error) {
        if s.Done {
            return minigraph.End, nil
        }
        return "tools", nil // else: run tools, then loop back to the model
    }).
    AddEdge("tools", "agent").
    Compile()

final, err := app.Invoke(ctx, State{Question: "..."})
flowchart LR
    st([Start]) --> agent([agent])
    agent -- done --> fin([End])
    agent -- needs tools --> tools([tools])
    tools --> agent
    classDef n fill:#00ADD8,stroke:#007d9c,color:#fff;
    class agent,tools n;

And here's a full ReAct loop running on it, offline, right now:

$ go run ./examples/react
Question: What is twice the population of France?
Thought: I need France's population before I can double it.
Action: lookup[population of France]
Observation: 68000000
Thought: Now I double the population I found.
Action: calculate[68000000 * 2]
Observation: 136000000
Final Answer: Twice the population of France is 136000000.
go get github.com/tomerfooks/minigraph

Why so small

LangGraph is excellent — and enormous. MiniGraph keeps the ideas that carry their weight and drops the machinery you rarely touch.

LangGraph core + checkpoints (Python)  ████████████████████████████  ~33,700 loc
MiniGraph (Go)                         ▏  420 loc

Same shape — typed state, conditional edges, cycles, invoke/stream, checkpointers, interrupts, parallel fan-out — at ~1.2% of the code, with state checked by the compiler instead of a runtime schema.

The entire API

Not "getting started" — this is the whole surface:

minigraph.Start, minigraph.End          // reserved endpoints of every run
type Node[S any]   = func(ctx, S) (S, error)      // transforms the state
type Router[S any] = func(ctx, S) (string, error) // picks the next node

New[S]().AddNode(…).AddEdge(…).AddRouter(…).Compile() → (*App[S], error)

app.Invoke(ctx, state)                  // run to completion
app.Stream(ctx, state)                  // iter.Seq2[Step[S], error] — just range over it
app.InvokeFrom(ctx, step)               // resume any yielded Step (every Step is a checkpoint)
app.StreamFrom(ctx, step)
app.InvokeThread(ctx, saver, id, state) // durable runs: load, run, save every step
app.StreamThread(ctx, saver, id, state)
app.MaxSteps                            // runaway-cycle guard, default 25

Parallel(merge, branches...)            // concurrent fan-out/join, packaged as one Node
&Interrupt{Payload: …}                  // return from a node to pause for a human
Checkpointer[S] · MemorySaver[S]        // persistence interface + in-memory impl
Step[S]{Node, State}                    // one executed step — and a resume point

If you've read this far, you already know the library.

The patterns

Human-in-the-loop. A node pauses by returning an Interrupt; unlike an error, the state it returns is kept. Answer by editing that state and resuming — the run picks up as if the pause never happened.

flowchart LR
    draft([draft]) --> approve{approve?}
    approve -- Interrupt --> human[[human edits state]]
    human -- InvokeFrom --> approve
    approve -- yes --> send([send])
    classDef n fill:#00ADD8,stroke:#007d9c,color:#fff;
    class draft,send n;
final, err := app.Invoke(ctx, initial)
var intr *minigraph.Interrupt
if errors.As(err, &intr) {
    final.Approved = true // the human's answer, written into the state
    final, err = app.InvokeFrom(ctx, minigraph.Step[State]{Node: intr.Node, State: final})
}

Parallel fan-out. Parallel folds N concurrent branches into one node, with a merge you write. And because a compiled graph's Invoke is a Node, whole subgraphs can be branches — nested parallelism with no special support.

flowchart LR
    r([research]) --> web([web]) & docs([docs]) & db([db])
    web & docs & db --> m{{merge}} --> w([write])
    classDef n fill:#00ADD8,stroke:#007d9c,color:#fff;
    class web,docs,db,w n;
research := minigraph.Parallel(mergeFindings, searchWeb, searchDocs, subgraph.Invoke)

Durable threads. Every yielded Step is a checkpoint; a Checkpointer persists the latest one per thread. Crash anywhere — a deploy, a panic, a pulled plug — run the thread again and it continues where it stopped. Failed steps aren't saved, so a rerun retries them for free.

saver := &minigraph.MemorySaver[State]{}
final, err := app.InvokeThread(ctx, saver, "thread-42", initial)

Four runnable examples, no API keys required:

go run ./examples/agent      # minimal agent ⇄ tools loop
go run ./examples/react      # ReAct: Thought → Action → Observation, swappable mock LLM
go run ./examples/approval   # human-in-the-loop on a durable thread
go run ./examples/fanout     # parallel researchers merged into one report

Coming from LangGraph

LangGraph MiniGraph
StateGraph(State) New[State]() — state is any Go type, checked at compile time
node function func(ctx, S) (S, error)
add_edge / add_conditional_edges AddEdge(from, to) / AddRouter(from, router)
START / END minigraph.Start / minigraph.End
compile()invoke / stream Compile()Invoke / Stream
recursion limit App.MaxSteps
checkpointer + thread_id Checkpointer, InvokeThread / StreamThread
interrupt() return &Interrupt{…}; InvokeFrom to continue
Send / parallel supersteps Parallel(merge, branches...)
subgraphs free — App.Invoke is a valid Node

Design, in one breath

A node returns the whole next state, so there are no reducers — which is how MiniGraph skips LangGraph's largest subsystem. Every node has exactly one outgoing edge (a static edge is a router that ignores the state), so control flow has one place to look. A run is a (node, state) pair advancing, so every step is a checkpoint — interrupts, durability, and retries fall out of that single fact. Parallelism lives in Parallel, where you write the merge, keeping the graph deterministic. And wiring mistakes fail at Compile, joined, all at once — not at 2 a.m., one at a time.

Out of scope, on purpose: per-key reducers, token streaming, retry policies, the platform layer. Nodes are plain functions; everything else composes from outside.

FAQ

Is it production-ready? It's 420 lines with more test code than library code. Read it over one coffee and you'll know it better than most of your dependencies.

Where are the LLM bindings? There aren't any. A node is func(ctx, S) (S, error) — call your model inside one. Any client, any provider, no adapter layer.

Why not just use LangGraph? If you're in Python, do. If you're in Go and want the whole runtime in your head, welcome home.

Development

go test -race ./...   # linear & cyclic runs, compile-time validations, streaming,
                      # cancellation, interrupts, durable threads, concurrency

License

MIT © Tomer Fooks

Documentation

Overview

Package minigraph is MiniGraph — LangGraph for Go, 1.2% of the code. A state machine of nodes that transform a shared, typed state, wired with static or conditional edges. Build a Graph, Compile it, then Invoke or Stream.

Index

Constants

View Source
const (
	Start = "__start__"
	End   = "__end__"
)

Start and End are the reserved endpoints of every run: wire Start to your entry node, and route to End to finish.

Variables

View Source
var ErrMaxSteps = errors.New("minigraph: max steps exceeded")

ErrMaxSteps is returned when a run exceeds App.MaxSteps node executions — usually a cycle that never routes to End.

Functions

This section is empty.

Types

type App

type App[S any] struct {

	// MaxSteps caps node executions per run, catching endless cycles.
	// Compile sets it to 25; override freely.
	MaxSteps int
	// contains filtered or unexported fields
}

App is a compiled, immutable graph. Safe for concurrent use.

func (*App[S]) Invoke

func (r *App[S]) Invoke(ctx context.Context, state S) (S, error)

Invoke runs the graph to completion and returns the final state. On error it returns the last state a node produced successfully.

func (*App[S]) InvokeFrom

func (r *App[S]) InvokeFrom(ctx context.Context, from Step[S]) (S, error)

InvokeFrom runs the graph to completion from a previously yielded Step.

func (*App[S]) InvokeThread

func (r *App[S]) InvokeThread(ctx context.Context, saver Checkpointer[S], thread string, initial S) (S, error)

InvokeThread runs a thread to completion (or its next Interrupt) and returns the final state. Calling it again on a finished thread is a no-op that returns the same final state. To answer an Interrupt, edit the returned state, Save it under the interrupted node, and call InvokeThread again:

saver.Save(ctx, thread, Step[S]{Node: intr.Node, State: edited})

func (*App[S]) Stream

func (r *App[S]) Stream(ctx context.Context, state S) iter.Seq2[Step[S], error]

Stream runs the graph from Start, yielding after every node. An error arrives as the final pair, with the Step beside it carrying the last completed node and last good state — so resuming an error Step retries the step that failed. Breaking out of the loop stops the run.

func (*App[S]) StreamFrom

func (r *App[S]) StreamFrom(ctx context.Context, from Step[S]) iter.Seq2[Step[S], error]

StreamFrom continues a run from a previously yielded Step: routing restarts from from.Node with from.State, so the checkpointed node is not re-executed. MaxSteps counts from zero again.

func (*App[S]) StreamThread

func (r *App[S]) StreamThread(ctx context.Context, saver Checkpointer[S], thread string, initial S) iter.Seq2[Step[S], error]

StreamThread is Stream with durability. If the thread has a saved Step the run resumes from it and initial is ignored; otherwise the run starts fresh. Every successful Step is saved before it is yielded, and an Interrupt's Step is saved too — so a process can die at any point and the thread continues where it left off. Ordinary node errors are not saved: rerunning the thread retries from the last good Step.

type Checkpointer

type Checkpointer[S any] interface {
	Save(ctx context.Context, thread string, step Step[S]) error
	Load(ctx context.Context, thread string) (Step[S], bool, error)
}

Checkpointer persists the latest Step of each thread, making runs durable: a run that crashed, was interrupted, or was broken out of continues from its last saved Step. Implementations should serialize S (e.g. JSON) when storing outside process memory.

type Graph

type Graph[S any] struct {
	// contains filtered or unexported fields
}

Graph is a mutable builder. Add nodes and edges, then Compile. Building mistakes accumulate silently and are all reported by Compile.

func New

func New[S any]() *Graph[S]

New returns an empty graph over state type S.

func (*Graph[S]) AddEdge

func (g *Graph[S]) AddEdge(from, to string) *Graph[S]

AddEdge wires from → to unconditionally. Every node has exactly one outgoing edge; from may be Start, to may be End.

func (*Graph[S]) AddNode

func (g *Graph[S]) AddNode(name string, fn Node[S]) *Graph[S]

AddNode registers fn under name. Names must be unique and not Start or End.

func (*Graph[S]) AddRouter

func (g *Graph[S]) AddRouter(from string, r Router[S]) *Graph[S]

AddRouter wires from → r(state), letting the state pick the next node at run time. r must return a node name or End.

func (*Graph[S]) Compile

func (g *Graph[S]) Compile() (*App[S], error)

Compile validates the graph and freezes it into a App. The builder can keep changing afterwards without affecting compiled runnables.

type Interrupt

type Interrupt struct {
	Payload any    // what the node wants to tell whoever resumes the run
	Node    string // set by the engine: the node that paused
}

Interrupt pauses a run for outside input — human approval, missing data, anything the graph can't decide alone. Return one from a node:

return s, &Interrupt{Payload: "ok to send this email?"}

Unlike an ordinary error, the state returned alongside an Interrupt is kept: the run yields it as the final Step, which is the checkpoint to continue from once the outside world has answered (usually after editing the state):

var intr *Interrupt
if errors.As(err, &intr) {
    state.Approved = true
    final, err = app.InvokeFrom(ctx, Step[S]{Node: intr.Node, State: state})
}

InvokeFrom routes onward from the interrupted node; the node itself does not re-run. Inside Parallel branches an Interrupt cannot pause the run and is treated as a plain error.

func (*Interrupt) Error

func (i *Interrupt) Error() string

type MemorySaver

type MemorySaver[S any] struct {
	// contains filtered or unexported fields
}

MemorySaver is an in-process Checkpointer, good for tests and single-run durability (interrupt/resume). The zero value is ready to use. Steps are stored by value: reference fields inside S still point at shared data.

func (*MemorySaver[S]) Load

func (m *MemorySaver[S]) Load(_ context.Context, thread string) (Step[S], bool, error)

func (*MemorySaver[S]) Save

func (m *MemorySaver[S]) Save(_ context.Context, thread string, step Step[S]) error

type Node

type Node[S any] func(ctx context.Context, state S) (S, error)

Node transforms the state: it receives the current state and returns the next one.

func Parallel

func Parallel[S any](merge func(ctx context.Context, base S, results []S) (S, error), branches ...Node[S]) Node[S]

Parallel composes branches into a single Node: every branch runs concurrently on the same starting state, then merge folds the results — ordered like the branches — into the next state. It is fan-out/join as a combinator: the graph stays sequential and this counts as one step.

Because a App's Invoke has a Node's signature, branches can be whole compiled subgraphs: Parallel(merge, subA.Invoke, subB.Invoke).

Branches receive shallow copies of the state, so reference fields (slices, maps, pointers) are shared: treat them as read-only inside a branch and put new data in the branch's own copy for merge to reconcile. The first branch error cancels the siblings and fails the node; an Interrupt inside a branch is treated as a plain error.

type Router

type Router[S any] func(ctx context.Context, state S) (string, error)

Router inspects the state after a node ran and names the next node, or End.

type Step

type Step[S any] struct {
	Node  string
	State S
}

Step is one node execution: the node's name and the state it produced. Every Step is also a checkpoint — feed one to InvokeFrom or StreamFrom to continue a run from exactly that point.

Directories

Path Synopsis
examples
agent command
Command agent shows the classic LangGraph shape — an agent that keeps calling tools until it can answer — as a minigraph over a typed state.
Command agent shows the classic LangGraph shape — an agent that keeps calling tools until it can answer — as a minigraph over a typed state.
approval command
Command approval shows human-in-the-loop with interrupts and a durable thread: an agent drafts an email, pauses for approval, incorporates rejection feedback, and only sends once a human says yes.
Command approval shows human-in-the-loop with interrupts and a durable thread: an agent drafts an email, pauses for approval, incorporates rejection feedback, and only sends once a human says yes.
fanout command
Command fanout shows parallel fan-out/join: three researchers run concurrently as one graph step via Parallel, a merge folds their findings, and a writer summarizes.
Command fanout shows parallel fan-out/join: three researchers run concurrently as one graph step via Parallel, a merge folds their findings, and a writer summarizes.
react command
Command react runs a ReAct (Reason + Act) agent on minigraph.
Command react runs a ReAct (Reason + Act) agent on minigraph.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL