Developer Docs

Build AI Workflows
You Can Actually Debug

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."

🧠 The orchestrating agent — outside the workflow

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.

⚙️ AI operations — inside the workflow

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.

Quick Start

Start building reliable workflows

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.

1

Install the skills

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/

Bundle downloads & Windows / project-local install ↓

2

Design & compile your workflow

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
3

Integrate the MCP server

Register the compiled binary with your agent and the triage_ticket tool is live.

claude mcp add ticket-triager -- ./ticket-triager/ticket-triager -mcp

Alternatively: clone, build, run

Or start by running one of the built-in examples from the source repo.

terminal
# 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.

Claude Code Skills

Design & Generate Workflows with AI

Two Claude Code slash commands that turn a plain-English task description into a compilable Sparsi program — without writing any boilerplate by hand.

1

/sparsi-design

Describe 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.

  • Maximises deterministic ops; AI only where necessary
  • Iterative — refine the design before generating any code
  • Outputs a structured document you approve before proceeding
2

/sparsi-codegen

Pass 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.

  • Implements the design exactly — no improvisation
  • Handles custom ops, predicates, and coalesce patterns
  • Outputs a runnable binary; just add CLAUDE_API_KEY
Claude Code — typical session
# 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

Installation

Skills work with Claude Code and Gemini CLI. Download the bundles below, then copy to your skills directory:

sparsi-design.zip sparsi-codegen.zip

macOS / Linux — global install

terminal
# 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)

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)

terminal
# 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 →

Manual Authoring

Or build a workflow by hand

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.

main.go
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.

Core Concepts

Three things to understand

det

Deterministic Ops

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.

  • Pure function semantics
  • Unit-testable without API keys
  • Results are cacheable
  • Generated boilerplate via daggen
AI

AI Ops

Generic AIComputeOp[In, Out] embeds a Claude call. Accepts a SkipIf wire — when non-empty, the AI call is bypassed and that value forwarded instead.

  • Typed input/output pairs
  • Logs input, output, reasoning
  • Retry on parse failure
  • Deterministic-first fallback pattern
DAG workflow

The Graph

Vertices wired by named values (wires). The dagor engine resolves dependencies, executes independent branches in parallel, and propagates skips from conditional Condition(predicate) gates.

  • Fluent builder API
  • Conditional branches (Condition)
  • Map nodes for fan-out
  • Coalesce for fan-in
Featured Example

Customer Support Ticket Triager

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.

ContextVal ticket_body ModeSelectOp → billing|bug|… AI gate_billing if category=billing gate_bug if category=bug gate_feature if category=feat. gate_other if category=other AIExtractMap (billing fields) AIParseNumber (refund amount) AIExtractSteps (repro steps) AIScore (severity 0..1) AIBool (is regression?) AISummarize (feature desc) AIScore (biz impact) AISummarize (acknowledgement) CoalesceN first non-nil wins JSON brief ✓ deterministic op AI op (fires only in matched lane)

Simplified from examples/ticket-triager/main.go — annotations inline:

examples/ticket-triager/main.go — buildGraph()
// 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:

stdout
{
  "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.

Decision Guide

Deterministic op or AI op?

det op

Use a deterministic op when…

  • The transformation has an exact, known formula
  • The logic can be unit-tested without a network call
  • The same input should always produce the same output
  • Performance is critical (sub-millisecond)
  • You need the result to be cacheable

Examples: string parsing, math, lookups, type conversion, data validation

AI op

Use an AI op when…

  • The logic requires natural language understanding
  • Classification into open-ended categories is needed
  • The deterministic library has no op covering this gap
  • Human-quality judgment is the requirement
  • A SkipIf wire can bypass AI on cache hits

Examples: sentiment, extraction, summarisation, scoring, open-ended generation

The deterministic-first / AI-fallback pattern

Use SkipIf to try a deterministic lookup first and only call AI on a miss. As the lookup table grows, AI calls shrink automatically.

library op wiring
// 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").
The Decision Line

What belongs in code — and what belongs in AI

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.

deterministic

Handle this in code

AI

Hand this to AI

Conditional routing

Once a category is known, every downstream branch is a fixed rule — no model, no tokens, the same path every time.

Free-form classification

Sorting a messy customer message into the right category needs language understanding — keyword matching misfires on the edge cases that matter.

Structured parsing

Log lines, dates, IDs, and any format you can describe as a pattern parse deterministically. No model needed.

Extraction from prose

Pulling a name, an account, and an amount out of a freehand email, where the wording and order change every time.

Formula scoring

Difficulty, risk, priority — any score that's a formula over known numbers is plain arithmetic, not a judgment call.

Subjective judgment

"How severe is this?" or "what's the business impact?" — calls that need human-quality reasoning, not a formula.

Operators AIScoreOp

Threshold decisions

"Over the limit?", "outside the window?" — these are policy in code, not questions for a model. The numbers are facts; the thresholds are yours.

Semantic yes/no

"Does this describe a regression?" — answerable from meaning, not from whether a particular word appears.

Operators AIBoolOp

Lookups with fallback

Check a known table first; only fall through to AI on a miss. Most cases never reach the model at all.

Ranking & matching

Choosing the most relevant option needs comprehension of both the request and every candidate — not a string match.

Arithmetic

Sums, products, clamps, rounding — once the numbers are in hand, the maths is fixed and reproducible.

Messy number parsing

Turning "$1.2k", "two thousand", or "about 40–50" into one canonical number when the phrasing is unconstrained.

Operators AIParseNumberOp

Calls to your systems

Fetching and parsing well-formed responses from your existing services is deterministic. AI is only needed for unstructured prose.

Summarization & drafting

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.

More Examples

Other patterns in the repo

02 — Recipe Analyzer

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 scoring

03 — README Quality

Fetches main and master branches concurrently, runs five parallel AI quality probes, computes average score, routes through quality bands.

parallel HTTP parallel AI probes

04 — Weather Advisor

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 op
Custom Operators

Build Your Own Operators

Any Go struct that implements operator.IOperator can be registered and wired into a graph — no framework internals required.

The IOperator interface

Every operator must implement seven methods. For most operators, five of them are pure boilerplate — only Run and SetInputField require real logic.

Business logic

  • Setup(params *config.Params) error — parse vertex params; called once before first Run
  • Run(ctx context.Context) error — read inputs, write outputs; the heart of your operator
  • SetInputField(field string, value any) error — type-assert and store an incoming wire value

Boilerplate (usually one-liners)

  • Reset() error — clear any state after Run; return nil if stateless
  • InputFields() map[string]any — expose input field pointers for the engine & logger
  • OutputFields() map[string]any — expose output field pointers for downstream wiring
  • ResetFields() — zero all fields so the pooled instance is clean for reuse

Field 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.

AI

Run() is just Go — including API calls

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:

  • Call any LLM API (Anthropic, OpenAI, Gemini …) and write the parsed response to an output field
  • Hit internal microservices, databases, or search indexes
  • Fan out to multiple downstream APIs and aggregate the results before returning

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.

Example — SayHelloOp

Receives a Name wire and writes "Hello {name}" to a Greeting wire.

myops/say_hello_op.go
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)
    }
}

Wire it into a graph

Blank-import the package so init() runs, then reference the operator by name in the builder:

main.go
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.

Onboarding shortcut

Run keyless with --sampling

Every -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.

When to use it

  • First-run / demo of a freshly generated workflow before you provision an API key.
  • Sharing a single binary with a colleague — they register it in their own Claude Code and try it without any setup.
  • Internal evaluation workflows where the AI ops are model-agnostic (classification, summarization) and the host model is fine.

When NOT to use it

  • Production deployments — the host gets the final say on which model runs each op; your per-op provider / model become hints only.
  • High-fanout / batch workflows — sampling-aware clients typically prompt the user to approve each sampling request.
  • Anything tuned to a specific model's behavior (cost-optimized Haiku classifiers, Opus-grade reasoning steps). Pin the model with a real API key instead.
terminal — keyless trial run
# 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

How it works under the hood

--sampling is wired by the codegen skill into three small pieces of the generated -mcp entrypoint:

  1. An 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.
  2. Each tool-call handler installs the per-request *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.

Locking it off in production builds

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:

production.go
//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.

Beyond the basics

Capabilities that compose with the DAG workflow you just built

The same operator pattern extends to four advanced surfaces. Each has its own concept guide and at least one worked example.

Why Go

Go power and reliability
in your micro-services landscape

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.

🔌

Speaks every protocol in your stack

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.

🔑

Enterprise auth the mesh expects

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.

🛡️

Reliable codegen, reliable runtime

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.

Start building your first DAG workflow

All examples are runnable with a single CLAUDE_API_KEY env var.