Fetches up to 10 HackerNews stories for a search query, fans out two AI checks per story using MapOver nodes, filters and flattens results with deterministic custom ops, computes the dominant category, picks a brief style with ModeSelectOp, and summarises the kept stories in one of three formats: technical engineering newsletter, executive business brief, or policy memo.
What this example teaches
[]any wireFilterAndFlattenOp) that produce two output wires from a single Run() callDominantCategoryOp) that counts label frequencies and returns the mode — deterministic over variable-length AI outputMapOver fans AI ops across every story in parallel. A chain of deterministic ops reduces the results to a single dominant category. One AI classifier picks the brief style; one summariser writes it.
A MapOver vertex reads a []string wire, creates a sub-graph for each element, runs the sub-graph concurrently for each element, and collects results into a []any wire. The two MapOver nodes below operate on the same titles wire and run in parallel with each other — N×2 AI calls in roughly the time of one.
// Stage 2a: per-story relevance check.
// MapOver fans AIBoolOp once per title; results → relevant_flags []any.
b.Vertex("map_relevance").
Input("Items", "titles").
MapOver("title").
SubVertex("relevant").
Op("AIBoolOp").
Params(map[string]string{
"predicate": fmt.Sprintf("Is this title about %q?", query),
}).
Input("Input", "title").
Output("Result", "is_relevant").
CollectInto("is_relevant", "relevant_flags")
// Stage 2b: per-story label classification.
// Runs in parallel with map_relevance — same titles wire, no dependency.
b.Vertex("map_classify").
Input("Items", "titles").
MapOver("title").
SubVertex("classify").
Op("AIClassifyMultiLabelOp").
Params(map[string]string{
"categories": "technical,business,policy,human_interest,other",
}).
Input("Input", "title").
Output("Result", "labels").
CollectInto("labels", "label_lists")
FilterAndFlattenOp is a custom op with two output wires. It zips three parallel slices (titles, flags, label lists) in a single Run() call: titles where the flag is false are discarded; labels from kept titles are concatenated into a flat list. Pure Go — no AI.
func (op *FilterAndFlattenOp) Run(ctx context.Context) error {
titles, flags, lists := *op.Titles, *op.RelevantFlags, *op.LabelLists
n := min(len(titles), len(flags), len(lists))
for i := 0; i < n; i++ {
flag, ok := flags[i].(bool)
if !ok || !flag {
continue // skip non-relevant stories
}
op.KeptTitles = append(op.KeptTitles, titles[i])
if labels, ok := lists[i].([]string); ok {
op.AllLabels = append(op.AllLabels, labels...) // flatten labels
}
}
return nil
}
DominantCategoryOp tallies the flattened label list and returns the mode. Then ModeSelectOp — an AI op — reads the single dominant-category string and picks a brief style. This two-step pattern (count deterministically, classify AI-to-AI) keeps the aggregation logic in testable Go code while letting AI handle the semantic "what does this category mean for a brief?" question.
// Stage 4: deterministic frequency count → dominant label.
b.Vertex("dominant_cat").Op("DominantCategoryOp").
Input("AllLabels", "all_labels").
Output("Dominant", "dominant").
// Stage 5: one AI call maps dominant label → brief style.
// "technical" → "technical_brief", "policy" → "policy_brief", etc.
b.Vertex("mode_select").Op("ModeSelectOp").
Params(map[string]string{"categories": "technical_brief,business_brief,policy_brief"}).
Input("Input", "dominant").
Output("Result", "brief_style")
Each of three AISummarizeOp lanes carries a predicate on brief_style. Only one fires. The operation parameter defines the style: the technical lane groups stories by sub-topic; the business lane adds an impact list; the policy lane adds legislative framing.
lanes := []struct{ name, condition, operation string }{
{
name: "technical",
condition: "style_is_technical_brief",
operation: "summarize as a technical engineering newsletter: " +
"one bullet per story, grouped by sub-topic",
},
{
name: "business",
condition: "style_is_business_brief",
operation: "summarize as an executive business brief: " +
"3-sentence overview then a bulleted impact list",
},
{
name: "policy",
condition: "style_is_policy_brief",
operation: "summarize as a policy memo: " +
"list legislative items, affected parties, and timeline",
},
}
for _, lane := range lanes {
b.Vertex(lane.name+"_lane").Op("AISummarizeOp").
Condition(lane.condition).
ConditionInput("brief_style").
Params(map[string]string{"operation": lane.operation}).
Input("Input", "kept_titles").
Output("Result", lane.name+"_text")
}
Two test fixtures are included. golang produces a technical brief; eu-ai-act produces a policy brief.
export CLAUDE_API_KEY=<your key>
# Cached fixtures (offline)
go run . --query golang --cache
go run . --query "EU AI Act" --cache
# Live API
go run . --query golang
go run . --query kubernetes
# Explicit fixture file
go run . --query golang --fixture testdata/hn/golang.json
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, hn_topic_brief. The Go SDK derives the tool's input schema from the workflow's UserInput struct and validates every tools/call request against it, so an agent runs the whole fetch → fan-out → classify → brief pipeline with one tool call.
# Speak MCP over stdin/stdout instead of running once
go run ./examples/hn-topic-brief --mcp
Register it with any MCP client by pointing the client at that command:
{
"mcpServers": {
"hn-topic-brief": {
"command": "go",
"args": ["run", "./examples/hn-topic-brief", "--mcp"]
}
}
}
Measured on testdata/hn/golang.json (10 stories). MapOver fans out one AIBoolOp and one AIClassifyMultiLabelOp per story — all 20 calls run in parallel. Token cost scales linearly with story count.
| Op | Calls | Input tokens | Output tokens | Total tokens |
|---|---|---|---|---|
| AIBoolOp | 10 | 781 | 40 | 821 |
| AIClassifyMultiLabelOp | 10 | 911 | 52 | 963 |
| ModeSelectOp | 1 | 67 | 6 | 73 |
| AIComputeOp | 1 | 224 | 248 | 472 |
| Total | 22 | 1,983 | 346 | 2,329 |