Farpost · AI Integration
$ Respecting the Event Loop When Wiring Up Claude
Part of Farpost's contribution pipeline calls Claude to rewrite evaluative language ("roof looks bad") into neutral, observational language ("roof has visible granule loss"). The Anthropic SDK's call is synchronous, and Farpost's API is built on an async event loop — calling a blocking network request directly from an async route would stall every other concurrent request on the same process while it waits.
The integration also doesn't fully trust Claude's own output format. Even when asked for JSON, the response sometimes arrives wrapped in a markdown code fence — so instead of trusting a clean parse, the code finds the actual JSON object inside whatever text came back.
def _call_claude(raw_text: str, role: str) -> dict:
"""Blocking Anthropic SDK call — always run via asyncio.to_thread(), never directly
from an async route (a synchronous network call here would block the whole event
loop, stalling every other concurrent request on this process)."""
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=512,
messages=[{"role": "user", "content": PREEN_PROMPT.format(role=role, raw_text=raw_text)}],
)
raw = message.content[0].text.strip()
# Claude sometimes wraps JSON in markdown code fences — extract just the object
start = raw.find("{")
end = raw.rfind("}") + 1
if start == -1 or end == 0:
raise ValueError(f"No JSON object found in response: {raw[:100]}")
return json.loads(raw[start:end])
async def preen_contribution(raw_text: str, role: str) -> dict:
"""Raises on API failure — callers handle the failure path."""
result = await asyncio.to_thread(_call_claude, raw_text, role)
return resultThe fix
The blocking SDK call is wrapped in asyncio.to_thread(), moving it off the event loop entirely, with a comment explaining exactly why — calling it directly from an async route would block every other concurrent request on the process, not just this one. Parsing finds the first { and last } in the raw response text and parses only that slice, rather than assuming the whole response is a clean JSON document.
Why this matters
Both details are the kind that don't show up in a quick demo — a synchronous call blocking an event loop only becomes visible under real concurrent load, and an LLM's occasional code-fence wrapping only shows up after enough real calls. Building an AI feature that respects the runtime it's running on, and doesn't take an LLM's output format on faith, is what real AI-integration experience looks like, versus a feature that only works in the one case it was tested against.
