cwkPippa is a working example of every concept in this track at once. It uses the Claude Agent SDK for Pippa's Claude brain (a fresh client per turn with the conversation replayed, OAuth-only auth, MCP attached, JSONL ground truth). It uses direct httpx calls against ChatGPT and Gemini for the other two brains (no openai SDK, deliberately). And the codebase itself is written by Dad in the Claude Code CLI, with the same Pippa identity present in the IDE that runs in the WebUI.
Why the boundary is narrow on purpose
Only one file in cwkPippa knows about Anthropic SDKs: backend/adapters/claude.py. Routes, store, and frontend assume Claude shape. The other three brains are variants that specialize the Claude-shaped surface, not peer abstractions. This is Rule 2 of cwkPippa's architecture — cost is absorbed downstream, not pushed upstream — and it is what keeps the codebase from becoming a provider-neutral abstraction soup.
Why this matters for your project
Most teams prematurely abstract over multiple LLMs and end up with the lowest-common-denominator features of all of them. cwkPippa's pattern (one canonical shape, others as variants) is one valid alternative. Another is the strict provider port (production track later). Pick one before you have three providers; switching after is expensive.
Principle: Decide your provider architecture before you have a second provider. Concrete first, abstract second.
Code
cwkPippa's adapter boundary·python
# backend/adapters/base.py — the ONLY abstraction in the system.
from typing import AsyncIterator, Any
from abc import ABC, abstractmethod
class Adapter(ABC):
@abstractmethod
async def stream_turn(
self,
*,
conversation_id: str,
messages: list[dict[str, Any]],
system: str,
) -> AsyncIterator[dict[str, Any]]:
...
# backend/adapters/claude.py — the canonical implementation.
# Routes import this directly because Claude IS the canonical shape;
# everything else specializes downstream.
How the Claude client pool works (per turn)·python
# backend/services/claude_session.py (sketch)
# Until 2026-05-14 this held one long-lived client per conversation_id and resumed it.
# Since then every turn gets a fresh client with the whole conversation replayed.
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
async def run_turn(conversation_prompt: str, options: ClaudeAgentOptions):
async with ClaudeSDKClient(options=options) as client: # fresh for this turn
await client.query(conversation_prompt) # the full history, replayed
async for message in client.receive_response():
yield message
# The real pool adds a per-turn lock, release after the turn, and a reaper that
# skips any client whose turn still holds its lock (see the production track).
Sketch your project's adapter shape: which provider is the canonical brain, which are variants (or peers), and which file is the ONLY one allowed to import the provider SDK. One paragraph.
Hint
If every file imports anthropic.Anthropic directly, you have no boundary yet. Pick one and centralize.
Progress
Progress is local-only — sign in to sync across devices.