Esports: Dota 2 match review
Reviews one player (by OpenDota account id) in one match from OpenDota's free public API (no key needed). It recalls that player's latest reviews of other matches from local memory, so it can say whether a habit is recurring. Asking about the same player and match again returns the saved review (free) instead of comparing the match with itself.
bash
ANTHROPIC_API_KEY=... halo run examples/65-esports-review.halo \
--arg match_id=9072857576 --arg account_id=96183976halo
// ============================================================
// Esports: Dota 2 match review
// ============================================================
// Reviews one player (by OpenDota account id) in one match from
// OpenDota's free public API (no key needed). It recalls that player's
// latest reviews of *other* matches from local memory, so it can say
// whether a habit is recurring. Asking about the same player and match
// again returns the saved review (free) instead of comparing the match
// with itself.
//
// RUN: ANTHROPIC_API_KEY=... halo run examples/65-esports-review.halo \
// --arg match_id=9072857576 --arg account_id=96183976
// ============================================================
policy Budget : MaxSpend(0.10) & MaxWords(350)
tool OpenDota : REST("api.opendota.com/api")
store PastReviews : VectorStore(namespace: "dota-reviews")
// If a review runs over MaxWords, @correct asks the coach to shorten it.
@correct(2)
agent Coach [anthropic/claude-haiku-4-5, temp: 0.3]
system """
You are a Dota 2 coach. Be concrete: name the moments and numbers
(deaths, last hits, net worth, item timings). Give the player three
things to practice. Name heroes only from the heroes list (match
hero_id to id); never guess a hero. Earlier reviews are of the same player in other
matches: if one shows the same habit, say so; if there are none, say
this is the first review. If the account isn't in the match, say so.
"""
requires Budget
pipeline ReviewMatch(match_id: Number, account_id: Number) -> Result:
match_id
// Already reviewed this player in this match? Reuse that review instead of paying again.
|> PastReviews.search(query: "review", tags: ["match-{{match_id}}", "player-{{account_id}}"], order: "recent", limit: 1) as existing
|? {
(context.data.existing.length > 0) => System.Value(context.data.existing.0.content),
else => NewReview(match_id, account_id),
} as review
|> System.Print(context.data.review)
@retry(2, 3s)
pipeline NewReview(match_id: Number, account_id: Number) -> Result:
match_id
// 1. The match, and the hero list to turn hero_id into names (both free)
|* {
OpenDota.match(path: "/matches/{{match_id}}") as match,
OpenDota.heroes(path: "/heroes") as heroes
}
// 2. Keep the per-player stats (the full payload is large)
|> System.Pick(
context.data.match.players,
fields: ["account_id", "name", "player_slot", "hero_id", "kills", "deaths", "assists", "last_hits", "denies", "gold_per_min", "xp_per_min", "net_worth", "win"]
) as players
|> System.Pick(context.data.heroes, fields: ["id", "localized_name"]) as hero_names
// 3. This player's newest reviews of *other* matches
|> PastReviews.search(query: "review", tags: ["player-{{account_id}}"], exclude_tags: ["match-{{match_id}}"], order: "recent", limit: 3) as history
// 4. The review
|> Coach.review(
"Review the player with account_id {{account_id}} in match {{match_id}} (duration {{match.duration}}s, radiant_win {{match.radiant_win}}). The other rows are teammates and opponents, for comparison.",
context.data.players,
earlier_reviews: context.data.history.content,
heroes: context.data.hero_names
) as review
// 5. Remember it, tagged with its match and player, and return the review
|> PastReviews.log(content: context.data.review, tags: ["review", "match-{{match_id}}", "player-{{account_id}}"])
|> System.Value(context.data.review)