Examples 01 — Customer Support Ticket Triager
Example 01

Customer Support Ticket Triager

Reads a free-text support ticket, uses AI to classify it as billing / bug / feature / or other, then routes it through a category-specific extraction lane. The billing, bug, and feature lanes run AI ops tailored to their category and serialize the results into a structured JSON brief. The "other" lane is intentionally unsupported: a ticket that classifies as other aborts the run with an error rather than producing a brief — and when the workflow is exposed over MCP, that error flows straight back to the calling agent as a tool failure. It's a deliberate demonstration of an MCP server that punts on requests it can't fulfil instead of faking an answer.

What this example teaches

  • The fan-out / predicate-gate / coalesce pattern — one classifier splits into N parallel lanes; exactly one fires per run
  • How predicate-gated vertices prune entire lane subtrees when their condition is false (skip-propagation)
  • CoalesceNStringOp for N-to-1 merging: only the three supported lanes feed it; it returns the single non-nil wire
  • How a gated error-only op (RejectOtherOp) lets a workflow deliberately fail on inputs it shouldn't handle — and how that failure surfaces cleanly through the MCP tool boundary as a rejection the calling agent can act on
  • Per-vertex credential routing via a custom AIClientFactory — each AI op declares a credential_ref cost center so teams bill on their own API key
  • Running the same binary as a one-shot CLI or a stdio MCP server with --mcp
View source on GitHub
The workflow

Pipeline structure

One AI classification step at the top splits into four parallel lanes. Only the lane whose predicate matches the category actually executes — the others are pruned by skip-propagation. Three lanes produce a brief and coalesce into one result; the fourth, other, runs a single op that fails the run on purpose.

ContextValOp
ticket body
ModeSelectOp
AI · classify
billing lane
bug lane
feature lane
other lane → error
CoalesceN
merge 3→1

The other lane has no path into CoalesceN — when it fires, RejectOtherOp aborts the whole run before coalesce is ever reached.

det op

Deterministic steps

ContextValOp (body_const)

Reads the raw ticket text from context.Context and emits it as a wire. Registered via builtin.ContextValFactory[string]; the value is injected with context.WithValue before the graph runs.

LaneGateOp (×4)

A pass-through op with a Condition predicate. When the predicate is false, this vertex is skipped and skip-propagation prunes every downstream vertex in that lane — zero AI calls are made for the pruned lanes.

EncodeBillingOp / EncodeBugOp / EncodeFeatureOp

Custom ops that serialize each supported lane's outputs into a JSON envelope. Pure Go — no AI calls.

RejectOtherOp

The entire "other" lane. Its Run does nothing but return errOtherUnsupported. Sitting downstream of gate_other, skip-propagation prunes it for every supported category; when a ticket actually classifies as other the gate fires, this op runs, and the returned error aborts the whole graph.

CoalesceNStringOp

Scans the three supported lane wires (n: 3) and returns the first non-nil one. Since exactly one of those lanes ran, exactly one wire is non-nil. The "other" lane never reaches here — it fails the run first.

AI op

AI steps

ModeSelectOp — classify

One AI call. Reads the ticket and returns exactly one of billing, bug, feature, or other. This value gates all four lane predicates.

Billing lane: AIExtractMapOp + AIParseNumberOp

Extracts structured fields (name, email, account_id, amounts) from freehand prose, then parses the refund amount to a float. Both run in parallel within the lane.

Bug lane: AIExtractStringSliceOp + AIScoreOp + AIBoolOp

Extracts reproduction steps, scores severity 0–1, and checks whether the report describes a regression. All three run in parallel.

Feature lane: AIComputeStringToStringOp + AIScoreOp

Summarises the requested feature in one sentence and scores its business impact. Both run in parallel.

Other lane: no AI

The "other" lane spends zero AI calls past the classifier. It exists only to reject — RejectOtherOp fails the run, so an unsupported ticket never burns extraction tokens.

Implementation

Walkthrough

Five key patterns to study in main.go.

1. Registering predicates that gate the lanes

Before building the graph, four predicates are registered — one per lane. Each checks the ticket_category wire for its expected value. When the AI classifier sets that wire to "billing", only lane_is_billing returns true.

main.go — registerPredicates()
func registerPredicates() {
    for _, lane := range []string{"billing", "bug", "feature", "other"} {
        want := lane
        name := "lane_is_" + lane
        predicate.Register(name, func(inputs map[string]any) bool {
            v, ok := inputs["ticket_category"].(*string)
            return ok && v != nil && *v == want
        })
    }
}

2. Classifier → lane gate → AI extraction

The graph is built with a fluent builder. ModeSelectOp classifies the ticket. Each lane starts with a LaneGateOp that carries a .Condition() — when the predicate is false, the vertex is skipped and the engine propagates that skip to every downstream vertex in the lane automatically.

main.go — buildGraph() (excerpt)
// One AI call classifies the ticket.
Vertex("classify").Op("ModeSelectOp").
    Params(map[string]string{"categories": "billing,bug,feature,other"}).
    Input("Input", "ticket_body").
    Output("Result", "ticket_category").

// Gate: only runs when ticket_category == "billing".
Vertex("gate_billing").Op("LaneGateOp").
    Condition("lane_is_billing").
    ConditionInput("ticket_category").
    Input("Body", "ticket_body").
    Output("BodyOut", "billing_body").

// Two parallel AI extractors in the billing lane.
Vertex("billing_extract").Op("AIExtractMapOp").
    Params(map[string]string{"operation": "extract name, email, account_id ..."}).
    Input("Input", "billing_body").
    Output("Result", "billing_map").

Vertex("billing_refund").Op("AIParseNumberOp").
    Params(map[string]string{"operation": "refund amount in US dollars"}).
    Input("Input", "billing_body").
    Output("Result", "billing_refund_amount").

3. Merging the three supported lanes into one result

CoalesceNStringOp takes N input wires and returns the first non-nil one. Only the three supported lanes feed it (n: 3) — the "other" lane has no wire here because it fails the run before coalesce is reached. Because exactly one supported lane ran, exactly one encode op produced a non-nil wire. The Merge(config.MergeCoalesce) call tells the engine to wait for all inputs before evaluating, even though two will be nil (skipped).

main.go — buildGraph() (coalesce)
Vertex("final").Op("CoalesceNStringOp").
    Params(map[string]int{"n": 3}).
    Merge(config.MergeCoalesce).
    Input("Input0", "billing_json").
    Input("Input1", "bug_json").
    Input("Input2", "feature_json").
    Output("Result", "final_brief").
// No Input for "other": that lane fails the run before this op.

4. Writing a custom deterministic op

Each lane needs a different JSON shape. Rather than a generic serializer, each lane gets its own typed op. Implementing the Operator interface requires Setup, Reset, Run, InputFields, OutputFields, SetInputField, and ResetFields. The Run method is pure Go and never makes an API call.

main.go — EncodeBillingOp
type EncodeBillingOp struct {
    Details      *map[string]string
    RefundAmount *float64
    Result       string
}

func (op *EncodeBillingOp) Run(_ context.Context) error {
    out := map[string]any{
        "category": "billing",
        "details":  *op.Details,
    }
    if op.RefundAmount != nil {
        out["refund_amount_usd"] = *op.RefundAmount
    }
    b, err := json.Marshal(out)
    op.Result = string(b)
    return err
}

5. Deliberately failing — and punting through MCP

The "other" lane is the interesting one. There is no encode op and no AI extraction — just RejectOtherOp, gated on lane_is_other, whose only job is to error. For every supported category the gate is skipped and skip-propagation prunes this op so it never fires; when a ticket genuinely classifies as other, the gate fires, Run returns errOtherUnsupported, and the whole graph run aborts.

main.go — RejectOtherOp
var errOtherUnsupported = errors.New(`ticket classified as "other": unsupported category`)

func (op *RejectOtherOp) Run(_ context.Context) error {
    return errOtherUnsupported
}

In CLI mode that aborted run exits the process non-zero. In MCP mode the tool handler catches it with errors.Is and returns it as a clean tool error — so the failure crosses the MCP boundary and lands back in the calling agent as a tool-call rejection it can read and act on, rather than a hung pipeline or a fabricated brief. This is the whole point of the example: an MCP server that punts on requests it can't fulfil.

main.go — runMCPServer() handler
res, err := runWorkflow(ctx, pool, in)
if err != nil {
    if errors.Is(err, errOtherUnsupported) {
        return nil, nil, fmt.Errorf(
            "this ticket was classified as \"other\" and cannot be triaged: " +
            "the triager supports only billing, bug, and feature tickets")
    }
    return nil, nil, err
}
return nil, res, nil

Run it

Four test tickets are included — one per category. The ai_nodes field in the output shows exactly which AI ops fired for that ticket type. The first three produce a JSON brief and exit 0; other.txt is the deliberate failure case — it exits non-zero with ticket classified as "other": unsupported category and produces no brief. Pass a file with --ticket, or inline text with --ticket-text.

shell
# Set your API key (or a per-cost-center key, e.g. CLAUDE_API_KEY_BILLING)
export CLAUDE_API_KEY=<your key>

go run ./examples/ticket-triager --ticket examples/ticket-triager/testdata/tickets/billing.txt
go run ./examples/ticket-triager --ticket examples/ticket-triager/testdata/tickets/bug.txt
go run ./examples/ticket-triager --ticket examples/ticket-triager/testdata/tickets/feature.txt

# Deliberate rejection: exits non-zero, no brief
go run ./examples/ticket-triager --ticket examples/ticket-triager/testdata/tickets/other.txt

Run it as an MCP server

The same binary is dual-mode. Pass --mcp and instead of running once and exiting it speaks the Model Context Protocol over stdin/stdout, exposing the entire workflow as a single MCP tool, triage_ticket. The Go SDK derives the tool's input schema from the workflow's UserInput struct and validates every tools/call request against it. A supported ticket returns the structured brief as the tool result; an other ticket returns a tool error — the calling agent sees "this ticket was classified as 'other' and cannot be triaged" and can re-route or ask the user, exactly as it would for any tool that declines a request.

shell
# Speak MCP over stdin/stdout instead of running once
go run ./examples/ticket-triager --mcp

Register it with any MCP client by pointing the client at that command:

.mcp.json
{
  "mcpServers": {
    "ticket-triager": {
      "command": "go",
      "args": ["run", "./examples/ticket-triager", "--mcp"]
    }
  }
}

Token usage

Measured on testdata/tickets/billing.txt. The billing lane fired; the other three lanes were pruned by skip-propagation — zero additional AI calls.

Op Calls Input tokens Output tokens Total tokens
ModeSelectOp 1 154 4 158
AIComputeOp 2 351 47 398
Total 3 505 51 556
← All Examples Next: Recipe Analyzer →