Sparsi is a DAG engine for packaging repeatable multi-step tasks into deterministic-first MCP tools. It replaces costly reasoning chains with a single call to a composed workflow—drastically reducing token burn and completion time by using AI only where it earns its keep.
Concretely, that's a DAG workflow: ops are plain Go structs, wires are typed values, and the engine handles dependency resolution, parallelism, and skip-propagation. You write the logic — this page is the how.
"Hand an agent fifty low-level tools and every problem means reinventing a long, brittle tool chain. Sparsi inverts this: package each business process as its own deterministic tool — with AI sealed inside it — so the calling agent reasons at the level of an executive or a support lead, not a plumber wiring primitives."
Decides which tool to invoke and what to do with the result. It sees one reliable call, never the ops inside. It never appears in your graph code.
Workflow vertices that interpret, transform, or route where no deterministic rule exists. Model-pinned, typed, logged. The graph invokes these — the agent never does.
No agent? The same compiled workflow runs unchanged as a standalone CLI binary or a batch job — the determinism, audit trail, and bounded cost are properties of the graph, not of being agent-driven.
The fastest path into Sparsi is the two Claude Code skills. Install them once,
then hand Claude a plain-English workflow description — it designs the DAG,
generates the Sparsi program, and compiles it for you.
Download the two skill bundles and drop them into your skills directory. Works with Claude Code and Gemini CLI.
# macOS / Linux
cp -r sparsi-design ~/.claude/skills/
cp -r sparsi-codegen ~/.claude/skills/
Paste the prompt below into your editor. It designed a customer-support ticket triager: an AI classifier fanning out into category-specific extraction lanes, coalesced into one JSON brief.
/sparsi-design Build a customer-support ticket triager. One AI op classifies a
free-text ticket. Route each category to its own AI extraction lane.
Expose the whole workflow as an MCP tool named triage_ticket.
# ... approve the design, then generate and compile ...
/sparsi-codegen <approved-design> ./ticket-triager
Register the compiled binary with your agent and the triage_ticket tool is live.
claude mcp add ticket-triager -- ./ticket-triager/ticket-triager -mcp
Or start by running one of the built-in examples from the source repo.
# 1 — clone the repo
git clone https://github.com/akennis/sparsi-go
cd sparsi-go
# 2 — build everything: operator library, examples, tooling
go build ./...
# 3 — run an example (a model call needs your Claude API key)
export CLAUDE_API_KEY=<your key>
go run ./examples/ticket-triager
Prerequisites: a recent Go toolchain (the minimum
version is pinned in the repo's go.mod), git, and a
Claude API key from
console.anthropic.com,
exported as CLAUDE_API_KEY. Deterministic ops need no key — only steps
that call a model read it. More runnable programs are on the
Examples page.
No key yet? Every generated -mcp binary
accepts a --sampling flag that routes its AI ops through
the connected MCP client's host LLM via
sampling/createMessage. Register the binary in Claude Code
with ./your-workflow -mcp --sampling and try the workflow
end-to-end without setting CLAUDE_API_KEY. See
Sampling override
below for trade-offs and when to switch back to a real key.
Two Claude Code slash commands that turn a plain-English task description into a compilable Sparsi program — without writing any boilerplate by hand.
/sparsi-designDescribe your task in plain English. The skill reads the full operator library and design rules, picks the most structurally similar example, then produces a structured DAG workflow design — ASCII diagram, vertex list, predicates, and rationale.
/sparsi-codegenPass the approved design and an output directory. The skill generates main.go + go.mod, runs go mod tidy, then go build — fixing any compile errors automatically until the binary is clean.
CLAUDE_API_KEY# Step 1 — describe your task; iterate on the design until you approve it
/sparsi-design Classify customer support tickets, route each category
to a specialist AI extraction lane, and emit a structured JSON brief.
# (refine with follow-up messages until the design looks right)
# Step 2 — generate, compile, and fix any build errors automatically
/sparsi-codegen <paste the approved design above> ./my-triager
Skills work with Claude Code and Gemini CLI. Download the bundles below, then copy to your skills directory:
macOS / Linux — global install
# Claude Code
cp -r sparsi-design ~/.claude/skills/
cp -r sparsi-codegen ~/.claude/skills/
# Gemini CLI
cp -r sparsi-design ~/.gemini/skills/
cp -r sparsi-codegen ~/.gemini/skills/
Windows — global install (PowerShell)
# Claude Code
Copy-Item -Recurse sparsi-design "$env:USERPROFILE\.claude\skills\"
Copy-Item -Recurse sparsi-codegen "$env:USERPROFILE\.claude\skills\"
# Gemini CLI
Copy-Item -Recurse sparsi-design "$env:USERPROFILE\.gemini\skills\"
Copy-Item -Recurse sparsi-codegen "$env:USERPROFILE\.gemini\skills\"
Project-local install (current project only)
# Claude Code
cp -r sparsi-design .claude/skills/
cp -r sparsi-codegen .claude/skills/
# Gemini CLI
cp -r sparsi-design .gemini/skills/
cp -r sparsi-codegen .gemini/skills/
Bundle versions track the library — replace both skill directories when you upgrade Sparsi. Browse the skill source on GitHub →
The skills are the fast path. When you want full control — or you're folding a workflow into an existing Go service — author the graph directly with the builder API. Skill-generated and hand-written workflows compile to the same DAG.
import (
_ "github.com/akennis/sparsi-go/library"
"github.com/akennis/dagor/graph"
"github.com/akennis/dagor/operator"
"github.com/akennis/dagor/operator/builtin"
)
type inputKey struct{}
func init() {
operator.RegisterOpFactory("input_const",
builtin.ContextValFactory[string](inputKey{}))
}
g, _ := graph.NewBuilder("my-workflow").
Vertex("input_const").Op("input_const").
Output("Result", "text").
Vertex("transform").Op("AIComputeStringToStringOp").
Params(map[string]string{"operation": "summarize"}).
Input("Input", "text").
Output("Result", "summary").
Build()
ctx := context.WithValue(context.Background(), inputKey{}, input)
result, _ := engine.Run(ctx, g)
The builder API in full — vertices, typed wires, conditional gates, fan-out and merge — is covered in the DAG engine guide. Every operator you can wire in is catalogued in the Operator Library.
A Go struct with dag:"input" and dag:"output" field tags and
a Run(ctx) method. Outputs depend only on inputs — no side effects, no randomness.
daggenGeneric AIComputeOp[In, Out] embeds a Claude call. Accepts a
SkipIf wire — when non-empty, the AI call is bypassed and that value forwarded instead.
Vertices wired by named values (wires). The dagor engine resolves
dependencies, executes independent branches in parallel, and propagates skips
from conditional Condition(predicate) gates.
Condition)A real-world workflow that classifies a support ticket, routes it through a category-specific AI extraction lane, and emits a structured JSON brief — using exactly the right AI calls and nothing more.
Simplified from examples/ticket-triager/main.go — annotations inline:
// Registered in init():
// operator.RegisterOpFactory("body_const", builtin.ContextValFactory[string](ticketBodyKey{}))
func buildGraph() (*graph.Graph, error) {
return graph.NewBuilder("ticket_triage").
// 1. Read ticket body from context — injected at run time via context.WithValue
Vertex("body_const").Op("body_const").
Output("Result", "ticket_body").
// 2. Single AI call to classify: billing / bug / feature / other
// Only ONE AI op fires regardless of ticket content.
Vertex("classify").Op("ModeSelectOp").
Params(map[string]string{"categories": "billing,bug,feature,other"}).
Input("Input", "ticket_body").
Output("Result", "ticket_category").
// 3. Billing lane — gate vertex prunes subtree when predicate is false.
// ConditionInput("ticket_category") passes the wire to the predicate;
// the gate op itself does not see it.
Vertex("gate_billing").Op("LaneGateOp").
Condition("lane_is_billing").
ConditionInput("ticket_category").
Input("Body", "ticket_body").
Output("BodyOut", "billing_body").
// 4. Two AI ops in the billing lane — run in parallel (no dependency
// between them), both skipped automatically if gate was pruned.
Vertex("billing_extract").Op("AIExtractMapOp").
Params(map[string]string{"operation": "extract: name, email, account_id, total_amount"}).
Input("Input", "billing_body").
Output("Result", "billing_map").
Vertex("billing_refund").Op("AIParseNumberOp").
Params(map[string]string{"operation": "refund amount requested (USD)"}).
Input("Input", "billing_body").
Output("Result", "billing_refund_amount").
// ... bug / feature / other lanes follow the same gate → AI ops pattern ...
// 5. Coalesce: exactly one lane produced output; take the first non-nil.
Vertex("final").Op("CoalesceNStringOp").
Params(map[string]int{"n": 4}).
Merge(config.MergeCoalesce).
Input("Input0", "billing_json").
Input("Input1", "bug_json").
Input("Input2", "feature_json").
Input("Input3", "other_json").
Output("Result", "final_brief").
Build()
}
Example output for a bug ticket:
{
"category": "bug",
"details": {
"reproduction_steps": ["open settings", "click save", "observe crash"],
"severity": 0.85,
"is_regression": true
},
"ai_nodes": [
"ModeSelectOp",
"AIExtractStringSliceOp(bug.steps)",
"AIScoreOp(bug.severity)",
"AIBoolOp(bug.regression)"
]
}
The ai_nodes field lists every AI vertex that actually fired.
For a billing ticket: 2 AI ops. For a bug: 3 AI ops. For feature: 2 AI ops.
The unused lanes are pruned by skip-propagation — zero wasted tokens.
Examples: string parsing, math, lookups, type conversion, data validation
SkipIf wire can bypass AI on cache hitsExamples: sentiment, extraction, summarisation, scoring, open-ended generation
Use SkipIf to try a deterministic lookup first and only call AI on a miss.
As the lookup table grows, AI calls shrink automatically.
// Check the lookup map first (no API call).
Vertex("lookup").Op("StringLookupOp").
Params(map[string]string{"map": `{"hamburger":"ketchup","hotdog":"mustard"}`}).
Input("Key", "food").
Output("Result", "known_condiment"). // empty string on miss
// AI fires only when the lookup missed (known_condiment is empty).
Vertex("ai_suggest").Op("AIComputeStringToStringOp").
Params(map[string]string{"operation": "suggest a condiment for this food"}).
Input("Input", "food").
Input("SkipIf", "known_condiment"). // bypassed when non-empty
Output("Result", "condiment").
Inside a workflow, the boundary is sharper than most teams assume. Most "complex" steps are deterministic once you know the rule. AI is reserved for what genuinely needs language understanding or judgment — which is also what keeps cost and audit under control.
Once a category is known, every downstream branch is a fixed rule — no model, no tokens, the same path every time.
Sorting a messy customer message into the right category needs language understanding — keyword matching misfires on the edge cases that matter.
Log lines, dates, IDs, and any format you can describe as a pattern parse deterministically. No model needed.
Pulling a name, an account, and an amount out of a freehand email, where the wording and order change every time.
Difficulty, risk, priority — any score that's a formula over known numbers is plain arithmetic, not a judgment call.
"How severe is this?" or "what's the business impact?" — calls that need human-quality reasoning, not a formula.
"Over the limit?", "outside the window?" — these are policy in code, not questions for a model. The numbers are facts; the thresholds are yours.
"Does this describe a regression?" — answerable from meaning, not from whether a particular word appears.
Check a known table first; only fall through to AI on a miss. Most cases never reach the model at all.
Choosing the most relevant option needs comprehension of both the request and every candidate — not a string match.
Sums, products, clamps, rounding — once the numbers are in hand, the maths is fixed and reproducible.
Turning "$1.2k", "two thousand", or "about 40–50" into one canonical number when the phrasing is unconstrained.
Fetching and parsing well-formed responses from your existing services is deterministic. AI is only needed for unstructured prose.
Compressing a document, or writing a context-aware reply, needs genuine understanding and generation.
The practical test: if you could test it reliably without ever calling a model, it belongs in code. If the answer depends on reading a sentence and understanding what a human likely meant — hand it to AI.
Three AI extractors run in parallel over recipe text, feed a deterministic difficulty score, then route through easy/medium/hard lanes via predicates.
parallel AI fan-out deterministic scoringFetches main and master branches concurrently, runs five parallel AI quality probes, computes average score, routes through quality bands.
Extracts temp/precip/wind from live weather JSON, computes deterministic bands, packs signals, then calls AI for a single outfit recommendation.
det. band logic one AI opAny Go struct that implements operator.IOperator can be registered and wired into a graph — no framework internals required.
IOperator interface
Every operator must implement seven methods. For most operators, five of them are pure boilerplate —
only Run and SetInputField require real logic.
Setup(params *config.Params) error — parse vertex params; called once before first RunRun(ctx context.Context) error — read inputs, write outputs; the heart of your operatorSetInputField(field string, value any) error — type-assert and store an incoming wire valueReset() error — clear any state after Run; return nil if statelessInputFields() map[string]any — expose input field pointers for the engine & loggerOutputFields() map[string]any — expose output field pointers for downstream wiringResetFields() — zero all fields so the pooled instance is clean for reuseField convention: inputs are typically pointers (*string, *int …) so the engine can distinguish a missing wire (nil) from a zero value. Outputs are plain values (string, int …). Check for nil at the top of Run to return a clear error on missing required wires.
There is no restriction on what Run can do. It receives a context.Context,
so any network call that respects context cancellation works out of the box:
This is how the built-in AI ops are implemented — AIComputeOp, AIScore, and friends are all
plain IOperator structs whose Run methods call the Claude API.
If you need a model, prompt format, or response schema that the library does not cover,
write a custom AI op that does exactly what you need and wire it alongside the built-ins.
SayHelloOp
Receives a Name wire and writes "Hello {name}" to a Greeting wire.
package myops
import (
"context"
"fmt"
"log"
"github.com/akennis/dagor/config"
"github.com/akennis/dagor/operator"
)
type SayHelloOp struct {
Name *string `dag:"input"`
Greeting string `dag:"output"`
}
func (op *SayHelloOp) Setup(_ *config.Params) error { return nil }
func (op *SayHelloOp) Run(_ context.Context) error {
if op.Name == nil {
return fmt.Errorf("SayHelloOp: Name is nil")
}
op.Greeting = "Hello " + *op.Name
return nil
}
func (op *SayHelloOp) Reset() error { return nil }
// InputFields and OutputFields expose field pointers to the engine for
// wire binding and structured logging. The key must match the wire name
// used in Input()/Output() calls on the graph builder.
func (op *SayHelloOp) InputFields() map[string]any {
return map[string]any{"Name": &op.Name}
}
func (op *SayHelloOp) OutputFields() map[string]any {
return map[string]any{"Greeting": &op.Greeting}
}
func (op *SayHelloOp) SetInputField(field string, value any) error {
if field == "Name" {
v, ok := value.(*string)
if !ok {
return fmt.Errorf("SayHelloOp: expected *string for Name, got %T", value)
}
op.Name = v
}
return nil
}
func (op *SayHelloOp) ResetFields() {
op.Name = nil
op.Greeting = ""
}
// init registers the operator under the name "SayHelloOp".
// Import this package with a blank identifier to activate it:
// _ "yourmodule/myops"
func init() {
if err := operator.RegisterOp[SayHelloOp](); err != nil {
log.Fatalf("RegisterOp[SayHelloOp] error: %v", err)
}
}
Blank-import the package so init() runs, then reference the operator by name in the builder:
import (
_ "yourmodule/myops" // registers SayHelloOp via init()
"github.com/akennis/dagor/graph"
"github.com/akennis/dagor/operator/builtin"
"github.com/akennis/dagor/operator"
)
type nameKey struct{}
func init() {
operator.RegisterOpFactory("name_const",
builtin.ContextValFactory[string](nameKey{}))
}
g, _ := graph.NewBuilder("hello-world").
Vertex("name_const").Op("name_const").
Output("Result", "user_name").
Vertex("hello").Op("SayHelloOp").
Input("Name", "user_name").
Output("Greeting", "hello_msg").
Build()
ctx := context.WithValue(context.Background(), nameKey{}, "World")
result, _ := engine.Run(ctx, g)
// result["hello_msg"] → "Hello World"
For operators with many fields, the daggen tool auto-generates InputFields, OutputFields, SetInputField, and ResetFields from dag:"input" / dag:"output" struct tags — run go run cmd/daggen/main.go -type=SayHelloOp to get started.
--samplingEvery -mcp binary the codegen skill produces accepts a
--sampling flag. When the flag is set, the AI ops in the
workflow stop reading CLAUDE_API_KEY / GEMINI_API_KEY
and instead ask the connected MCP client to run each completion on the
host LLM via the protocol's sampling/createMessage reverse
call. The host pays the tokens; you pay nothing to take a workflow for
a spin.
provider / model become hints only.# Register the same binary you'd run in production — just add --sampling.
# Claude Code is sampling-aware; Claude Desktop is too. Other MCP clients
# may not be — the server fails fast at startup if the connected client
# does not advertise the sampling capability.
claude mcp add my-workflow -- ./my-workflow -mcp --sampling
--sampling is wired by the codegen skill into three small
pieces of the generated -mcp entrypoint:
InitializedHandler fires once when the MCP client connects.
It calls library.EnableSamplingOverride() and logs a
one-line startup banner on stderr ("sparsi: --sampling active
— every AI op will run on the host LLM") so the override is
observable. It also checks
session.InitializeParams().Capabilities.Sampling and
warns when the client did not advertise sampling — many clients
(Claude Code included) handle sampling/createMessage
without listing the capability, so the missing-cap branch is a
warning rather than a fatal; the actual round-trip on the first AI
op is the real source of truth.*mcp.ServerSession on ctx via
library.WithMCPServerSession. The library's internal
samplingCaller pulls that session out and issues
sampling/createMessage, forwarding the originally-declared
provider / model as
ModelPreferences.Hints so a sampling-aware host can still
match the workflow's intent.
For regulated environments or hosted services that must guarantee no
operator can swap the model out via --sampling, add a single
init() to a build-tagged file in the generated module:
//go:build production
package main
import "github.com/akennis/sparsi-go/library"
func init() { library.DisallowSamplingOverride() }
After that, --sampling is rejected at server startup. The
check is one-way — there is no Re-allow — so a build that includes the
production tag can never be coaxed back into sampling mode
by another init() or runtime path.
The same operator pattern extends to four advanced surfaces. Each has its own concept guide and at least one worked example.
Call any Model Context Protocol tool — local stdio subprocess or remote streamable HTTP — as a regular workflow vertex. MCPCallOp for one-shot calls; MCPScriptOp for stateful multi-call sessions. Warm-replenish pooling keeps cold-start off the critical path.
Worked examples: Playwright fan-out, Cloudflare docs search.
Implement library.Retriever for any backend (BM25, vector store, hosted search) and wire RetrieveOp into the graph. ValidateCitationsOp filters LLM-emitted source names against the retrieved allow-list — hallucinated citations land in Rejected, not in your UI.
Worked examples: BM25 + citations, Gemini embeddings.
Wrap a deterministic op with library.WithRepair so a typed *ErrRepairable escalates to an LLM that mutates the input until it parses or satisfies your business rules. Bounded by max_attempts; non-repairable errors propagate unchanged.
Worked example: two-stage ticket repair (JSON + XML).
Implement AIClientFactory (and optionally EmbeddingClientFactory) to source Anthropic, Gemini, and embedding-provider clients from Vault, AWS Secrets Manager, workload identity, or per-tenant credential stores — without env vars and without graph changes.
See also: DevOps for deployment posture.
A Sparsi DAG workflow lives inside your service mesh, not beside it. Every deterministic node is plain Go reaching out over the wire — so the workflow connects to any modern endpoint you already run, with the type safety and concurrency that keep it dependable under load.
A deterministic op is just Go calling a service: REST and JSON over HTTP/2,
gRPC and Protobuf, GraphQL, Kafka or NATS, S3, SQL and Redis — anything with a
Go client or a wire format you can wrap. IOHTTPGetOp and
MCPCallOp ship in the box; a custom op for an internal endpoint is
a few dozen lines. No sidecar, no translation tier — the workflow dials your
micro-services directly.
First-class JWT, OAuth2 / OIDC, mTLS, and request signing — the same
mechanisms your services already gate on. Pair it with AIClientFactory
to pull credentials from Vault, AWS Secrets Manager, or workload identity, and
a node authenticates to a downstream API exactly the way the rest of your fleet
does — a library call, not a research project.
Static typing catches generated workflow code at compile time, before a single request runs. Garbage collection removes a whole class of memory bugs. Goroutines make the workflow's parallel fan-out across services cheap and safe — concurrency you can trust without hand-managed threads, on a single static binary that drops cleanly into any container or VM.
The point: Sparsi doesn't ask your architecture to change around it. It fits into the micro-services landscape you already operate — same protocols, same auth, same deployment unit — and Go is what makes that fit reliable instead of fragile.