Inspiration

Open-source maintainers drown in the same questions. The answers almost always exist — in the source, in a closed issue, in a thread from three weeks ago that nobody can find — but finding them is work, and that work lands on the few people who can least afford it. Meanwhile a newcomer asks at 2am, waits two days for a reply, and quietly leaves.

Everyone's first instinct is "put an LLM on it." We think that instinct is dangerous. A support bot that answers confidently and wrongly is worse than no bot at all: it poisons the channel with plausible nonsense and wastes the maintainers it was supposed to help. They now have to clean up after it.

So we set one constraint above every other: Sprout must be checkable. Every claim points at something you can open and verify. When it can't ground an answer, it doesn't produce one — it fetches a human.

That constraint drove every design decision that follows.

What it does

Sprout lives in #tofu-help and answers like a good maintainer would — for OpenTofu, the open-source Terraform fork.

It answers with receipts. It searches the workspace's own history first, then grounds the answer in the actual OpenTofu repository. Every reply carries a Sources footer linking the exact eval_for_each.go:28-53 it relied on. Click it; check it.

It runs your code. Paste a broken config and Sprout executes tofu validate in an isolated Daytona sandbox, reads the real error, writes a fix, and then re-runs the sandbox on its own fix. It only says "fixed" once the sandbox agrees. It never claims a fix works because it looks right.

It explains an error from its source. Paste Error: Invalid for_each argument and it searches the literal error string inside the codebase, names the file that actually raises it — internal/lang/evalchecks/eval_for_each.go — and gives you the precise triggering condition: null values, or map keys that can't be known until apply. Not a generic list of maybes paraphrased back at you.

Deep mode. @Sprout deep: <question> clones the real repositories into a sandbox and turns an autonomous coding agent loose on the source. It decides which repos it needs on its own: ask about Flux CD and it pulls in both opentofu/opentofu and flux-iac/tofu-controller, unprompted. It streams a live plan block while it works, so you watch it clone and read.

Nobody gets left behind. A sweeper watches the channel and answers any question nobody picked up after a few minutes — the asked-at-2am case that quietly costs communities their newcomers.

It looks after the channel itself. A weekly digest (with charts) shows maintainers what people actually struggled with and where the docs have gaps — effectively a to-do list for the docs team. A thread TL;DR lets someone join a long thread cold. And an FAQ canvas writes itself from the questions the community really asked, refreshing in place so the link you shared never breaks.

It grows contributors. @Sprout contribute hands a newcomer real good-first-issues as browsable cards, each explained for someone who has never opened a pull request — plus a button to have Sprout walk them through what the fix would involve.

It learns. 👍 an answer and it becomes community-verified, and is preferred the next time someone asks.

And it knows when to stop. Ask it a roadmap, governance, or licensing question and it refuses to speak for the maintainers. It pings a human instead.

How we built it

All three qualifying technologies, each doing real work — not one of them bolted on for the checkbox:

  1. Slack AI / Assistants API — the agent surface: streamed answers, suggested prompts, App Home, the agent view.
  2. Our own MCP server — stdio transport, spawned as a child process and kept warm. Seven tools over the live OpenTofu project: repo_search, read_file, issue_search, release_notes, good_first_issues, contribution_guide, and sandbox_exec.
  3. Real-Time Searchassistant.search.context, so Sprout recalls what the workspace already answered instead of re-deriving it from scratch.

Around that: Bolt in Socket Mode (outbound WSS — no inbound ports to defend), the OpenAI Agents SDK running gpt-4.1-mini for the agent loop, gpt-5-codex for deep investigation and gpt-5-mini for the human-voice write-up, and Daytona sandboxes — one kept warm for tofu validate (~0.8s) and ephemeral ones for deep mode, running OpenCode over freshly cloned repositories. It runs always-on as a systemd service on AWS Lightsail.

We used Block Kit properly rather than dumping text into a message: answers are markdown blocks that Slack expands into native blocks server-side; the digest renders a real data_visualization chart alongside tables; deep mode ticks a live plan block; contribute is a carousel of cards; the draft-issue flow shows task_cards.

Challenges we ran into

Real-Time Search was silently disabled in production. In Socket Mode context.userToken is undefined, so the RTS tool quietly no-op'd. No error, no warning — just an agent that never remembered anything. We only found it by asking the bot what tools it actually had.

RTS cited a colleague's sales report from an unrelated channel as evidence about OpenTofu. A perfectly correct API call producing an absurd answer. Search had to be scoped to community channels.

Recall short-circuited the source. Once RTS found a related prior thread, the agent adopted its conclusion and stopped researching. So a pasted error got a plausible-but-wrong explanation inherited from an old thread instead of the truth from the code. The fix was to make grounding non-negotiable: a prior thread is a lead, never a citation.

A verified answer got over-generalised. Someone with a literal list was told to wrap it in toset() — correct for that config, and a human had up-voted it. The self-writing FAQ then promoted that fix into the headline definition of the error, which was wrong. The stored data was right; the summariser wasn't. Every "learns from its community" system has this failure mode and we're glad we caught ours.

Daytona's egress proxy re-signs TLS, so git clone fails inside the sandbox until you disable SSL verification; and registry.opentofu.org blocks its datacenter IPs, so only core features validate offline. Also $(git rev-parse) in an exec string gets evaluated by the outer shell, producing empty commit SHAs and a false "no repos cloned" — fixed by running the clone from an uploaded script.

Slack's newer Block Kit has undocumented limits. A chart is rejected in its entirety if any category label exceeds 20 characters, and data_table accepts no column_settings even though table does. Neither is in the docs. We found both by probing the API and reading the 400s.

Accomplishments that we're proud of

It is genuinely grounded. Ask why OpenTofu rejects for_each on a value that isn't known until apply, and it answers with internal/lang/evalchecks/eval_for_each.go — the file that actually raises the error — and the real triggering conditions. Not a paraphrase of the error message. You can click the citation, land on the exact commit, and check it yourself. That is the entire product.

Deep mode working out on its own that a Flux question needs two repositories, cloning both, and coming back with permalinks pinned to the commit it read — that still feels like magic, and there's no hardcoded mapping behind it.

And the restraint: it fetches a human rather than bluffing. That was the hardest thing to build and the easiest thing to skip.

What we learned

The hard part of a support agent isn't generating text. It's refusing to generate text you can't back up.

Almost every bug worth fixing in this project was a case of the agent being confidently wrong — and almost every fix was a mechanism to force it back to primary sources. The models are more than good enough. The engineering is all in the grounding, the verification, and knowing when to shut up.

What's next for Sprout — the OpenTofu community agent

Getting it into the real OpenTofu community Slack, and letting maintainers curate the verified-answer store directly. The docs-gaps the weekly digest surfaces are already a working to-do list for the docs team — we'd like to turn that into pull requests, not just observations.

And the architecture isn't OpenTofu-specific. Point the MCP server at a different repository and Sprout becomes that project's maintainer-in-the-channel.

Built With

Share this project:

Updates