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
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 onAIClientFactory — each AI op declares a credential_ref cost center so teams bill on their own API key--mcpOne 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.
The other lane has no path into CoalesceN — when it fires, RejectOtherOp aborts the whole run before coalesce is ever reached.
Five key patterns to study in main.go.
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.
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
})
}
}
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.
// 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").
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).
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.
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.
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
}
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.
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.
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
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.
# 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
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.
# 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:
{
"mcpServers": {
"ticket-triager": {
"command": "go",
"args": ["run", "./examples/ticket-triager", "--mcp"]
}
}
}
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 |