---
title: "Bagman"
description: "voice-driven golf scoring for groups playing multiple game types simultaneously. one mastra agent, twenty-two tools, ably realtime."
canonical: "https://bokendell.com/projects/bagman"
last-updated: 2026-08-25
---

# Bagman

voice-driven golf scoring for groups playing multiple game types simultaneously. one mastra agent, twenty-two tools, ably realtime.

## Metadata
- Canonical: https://bokendell.com/projects/bagman
- Markdown: https://bokendell.com/projects/bagman.md
- Lifecycle: shipping
- Tags: mobile, expo, ai, realtime
- Updated: 2026-08-25

## Source
```mdx
<CaseStudyHero
	title={meta.title}
	thesis={meta.thesis}
	metric={meta.heroMetric}
	statusRows={[
		{ label: "status", value: "in private beta" },
		{ label: "stack", value: "expo · hono · neon · ably · mastra" },
		{ label: "repo", value: "private — case study only", href: undefined },
	]}
	media={
		meta.heroVideo ? (
			<video
				src={meta.heroVideo}
				autoPlay
				muted
				loop
				playsInline
				style={{ width: "100%", height: "100%", objectFit: "cover" }}
			/>
		) : (
			<div
				style={{
					width: "100%",
					height: "100%",
					display: "flex",
					alignItems: "center",
					justifyContent: "center",
					fontFamily: "var(--font-data)",
					fontSize: 12,
					color: "var(--dim)",
					letterSpacing: "0.04em",
				}}
			>
				[ 8s silent hero loop · provide /videos/bagman-hero.mp4 ]
			</div>
		)
	}
/>

## 01 — problem

a serious golf group plays four games at once. skins on the front, nassau across nine, stableford
for the year-long ladder, and one custom game where a putt from off the green earns a beer. one
person carries a notepad. that person stops playing golf.

every existing scoring app i tried optimized for stroke play and shoved everything else into a
"custom games" tab that asked you to fill out a form per round. nothing handled the actual case:
the math runs continuously, the standings have to refresh in front of everyone at the same time,
and you should be able to enter scores without taking your hand off your beer.

## 02 — approach

<TechStackLine
	groups={[
		{ label: "mobile", items: ["expo (eas)", "react native", "react query", "zustand", "react hook form"] },
		{ label: "api", items: ["hono on railway", "tRPC", "drizzle"] },
		{ label: "data", items: ["neon serverless postgres", "ably realtime", "inngest workflows"] },
		{ label: "ai", items: ["mastra agents", "anthropic claude (sonnet + haiku)", "langfuse traces"] },
		{ label: "auth", items: ["better auth · magic link", "api-key plugin"] },
	]}
	whyOneLiner="every part is managed and serverless because i'm one person; the only place i write infrastructure is the realtime channel topology."
/>

the bookkeeper insight is that scoring is a transcription problem dressed up as a scoring problem.
"blake got 5, jake got 4, i got 6" is structured data wearing a sentence. one mastra agent owns
twenty-two tools (read round context, read player stats, configure games, submit scores, correct
scores, request clarification, log shots) and the agent doesn't have to be smart — it has to be
fast and correct. anthropic claude is the engine because tool-call accuracy at sonnet beats
everything else i tested at this price point, and haiku covers the routing tier.

<DecisionPair
	chosen={{
		name: "ably",
		bullets: [
			"managed presence + channels for multiplayer scoring",
			"native ios/android sdks (no socket plumbing in expo)",
			"single channel per round; clients fan out scores + game-state events",
			"per-message billing fits a round's actual traffic shape",
		],
	}}
	rejected={{
		name: "raw websockets",
		bullets: [
			"requires a stateful server (railway is fine, but presence isn't free)",
			"ios background-disconnect handling falls on me",
			"recovery semantics on flaky cellular = bug surface",
		],
	}}
	narrative="did not consider supabase realtime because the round channel needs presence and history-replay together, and ably is the only option that ships both as primitives without a tradeoff post."
/>

<DecisionPair
	chosen={{
		name: "haiku for routing + scoring; sonnet for caddy advice",
		bullets: [
			"routing decisions are structured (which tool, which player, which hole)",
			"haiku tool-call accuracy is sufficient on structured outputs",
			"per-message cost drops ~6× on the hot path",
			"perceived latency drops because the routing tier returns first",
		],
	}}
	rejected={{
		name: "single-tier sonnet for everything",
		bullets: [
			"caddy advice doesn't need to be on the hot path",
			"sonnet on every voice message wasted budget on routing",
			"first-token latency dominated TTFT for short utterances",
		],
	}}
	narrative="the sub-agent split is older than i'd like to admit — it took me three weeks to convince myself that haiku was good enough at structured outputs and sonnet only needed to handle the actually hard reasoning."
/>

<Mermaid
	caption="voice command lifecycle"
	chart={`flowchart LR
		mic[mic · expo-audio] --> api[hono · /ai/stream]
		api --> ctx[round context builder]
		ctx --> agent[mastra agent · haiku]
		agent --> tools[22 tools]
		tools --> db[(neon)]
		db --> ably[ably channel]
		ably --> phones[all players · live]
		classDef hot fill:#ca653c26,stroke:#ca653c,color:#eae3e1;
		class agent,ably hot
	`}
/>

<HardProblem
	headline="voice-to-action latency was the whole product"
	triedFirst="every voice message read round context as a tool call before deciding what to do."
	whyFailed="that was one llm round-trip plus one db round-trip in series before any output token. on a putting green over LTE, the user was already typing it manually by the time the agent answered."
>
	the fix wasn't a bigger model — it was removing a round-trip. the api now builds the full round
	context (active hole, players, scores, current games, weather, golf bag) in parallel and injects
	it as a system message before the agent runs. the agent still _has_ a `read_round_context` tool
	for repair queries, but it never needs it on the happy path. one fewer round-trip, on the hot
	path that matters most.
</HardProblem>

<HardProblem
	headline="ai-submitted scores were silent on every other phone in the round"
	triedFirst="action tools instantiated their own roundService inside the tool handler, kept it lightweight."
	whyFailed="the lightweight service had no realtime publisher, so scores wrote to the db successfully and never published an ably event. the scorecard didn't refresh anywhere except the device that issued the voice command. silently broken — the worst kind."
>
	rewrote with a `round-service.factory.ts` that constructs the full service graph (realtime,
	transactions, course service) for every tool invocation. tools no longer hand-build their
	dependencies — they receive a fully-wired service. caught it because i was in a round myself
	with three friends and noticed the standings didn't move on their phones for thirty seconds.
	the kind of bug you only find by playing the game.
</HardProblem>

<TryIt caption="four players · six holes · three game types. tap any score; watch all three standings recalculate.">
	<LiveScorecard />
</TryIt>

## 03 — result

<MetricStrip
	primary={{ value: "22", label: "ai tools, one agent" }}
	secondary={[
		{ value: "11", label: "game types built-in" },
		{ value: "333", label: "tests in the golf package" },
		{ value: "1 fewer", label: "llm round-trip per voice message", delta: "context pre-load" },
	]}
	context="one full mastra agent, two model tiers (haiku for routing, sonnet for caddy advice), realtime fan-out via ably. private beta; rounds and lighthouse data published once the public launch lands."
/>
```

_Generated from live portfolio data and MDX source. Canonical site: https://bokendell.com_
