The one-sentence API design test
Every public name gets one sentence. If a colleague would ask a follow-up, the name fails.
I asked six AI agents to each design one extension to a Python framework I’m building, and pointed them at open-source frameworks as prior art. Each came back with a thoughtful design and sample code. On its own, each one held up.
Side by side, they disagreed about things every user touches. The same step had different names in different proposals. Two of them added new ways to start an app, on top of the two the framework already had. One concept, AgentSession, meant something different depending on which proposal you read. And there was no way to say which of them was right, because I hadn’t written down what right meant.

In a second round, I gave one agent the job of a developer-experience critic: someone judging the design from the user’s side. It asked a single question about the core names a user would have to learn, wrote down the rules my existing code already followed, and turned those rules into checks anyone can count. That’s what I’ll walk through: the question, the rules, and the checks, each on the proposals that failed them.
The short version
- For every public name a new user has to learn, write one sentence explaining it. If a colleague would naturally ask a follow-up, the name fails.
- A sentence only works against what users already know, so write down the rules your existing code follows. Those are your API’s house style.
- Turn the rules into checks someone can count: new names, callbacks,
async, lines. Then a proposal can be judged without a debate about how it feels. - Agents fit their designs to the prior art they’ve read. Give them your rules before the prior art.
A newspaper with six writers
Think of a newspaper that hires six good freelance writers. Each files a strong piece. Put the six pieces on one page, though, and they spell the same word three ways, use two date formats, and call the same politician by different titles. None of the writers is wrong; the paper never gave them its style guide.
An API is a page every user reads. The framework’s existing code is its house style, whether or not anyone wrote it down, and every proposal is a freelancer’s piece. The critic’s job is the editor’s: check each piece against the style guide, and where there isn’t one, write it.
The one-sentence test
Take every public name a new user has to learn. Write one sentence that explains each. Then imagine saying it to a colleague: if they’d naturally ask a follow-up question, the concept fails.
Some of the names in the six proposals, with the sentence each one got:
| Name | One sentence, or the follow-up it gets | Result |
|---|---|---|
Pipeline | The container for your chain of steps. | ✅ |
Session | Runs your pipeline until it’s done. | ✅ |
>> | Connects two steps; data flows left to right. | ✅ |
@tool | Makes a function callable by the AI model. | ✅ |
AgentSession | ”An orchestrator? A step in the pipeline? A kind of session?” | ❌ |
VoiceAgent | ”A base class? Why not the app class I already have?” | ❌ |
Worker | ”A scaling wrapper? Why do I need to know about it?” | ❌ |
JobContext | ”A session? A pipeline? A bag of metadata?” | ❌ |
The follow-up is the question your users will otherwise answer for themselves, by guessing, and not all of them will guess the same way.
One name, three meanings
AgentSession is the one I’d remember, because each proposal’s use of it made sense on that proposal’s own terms. In the snippets below, ep is the app’s endpoint, its connection to the people on the other end of the call: audio arrives on ep.audio_in and leaves on ep.audio_out, and >> chains steps into the pipeline. One proposal put AgentSession in the pipeline like a step:
agent = AgentSession(model=..., tools=[...])pipeline = Pipeline(...)ep.audio_in >> agent >> ep.audio_outSession(pipeline).run()Another kept it inside a new app base class, where it runs the conversation from outside the pipeline:
class OutboundAgent(VoiceAgent): ...
def on_call_started(self, endpoint): self.agent_session.say("Hi! ...")And its name suggests a third thing: a kind of Session. Asking for one sentence forces you to pick. The critic picked “step”. If it sits in the >> chain, it is one, so name it like one: RealtimeModel, “a single step that handles the whole voice conversation”, which passes.
ep.audio_in >> RealtimeModel(model=..., tools=[...]) >> ep.audio_outWhen a name fails, work down this chart and stop at the first yes:

Splitting a name, or picking one meaning
The bottom two boxes of the chart look alike and aren’t. A name that needs two sentences within one design is doing two jobs, and should be split into two names, one sentence each. A name that different designs use differently, like AgentSession, has one job per design, so splitting doesn’t help: pick one meaning, and a name to match.
Where the sentences come from: the house style
A sentence is only “one sentence” relative to what the reader already knows, and for an existing API that’s the rules its current code follows. Writing them down makes that explicit. The critic read the framework’s existing example apps and listed what they had in common. Most have the same shape. Here’s the core of one, an app that mixes the audio of everyone in a call:
import os
pipeline = Pipeline("multiparty")ep = Endpoint.webrtc(signaling_url=os.environ["SIGNALING_URL"])ep.audio_in >> OpusDecoder() >> AudioMixer() >> OpusEncoder() >> ep.audio_outSession(pipeline, ...).run()Read it top to bottom: name a pipeline, open an endpoint with its address from the environment, chain three steps between the audio coming in and going out (decode the compressed audio, mix it, compress it again), and run it. And the rules it follows, the house style:
- An app starts with
Session(...).run(), or, for an app written as a class, by subclassing the framework’s one app class and callingrun(). - Steps are connected with
>>and created inside the chain, not assigned to a variable first. - Steps are named for what they do, with no suffix (
AudioMixer, notAudioMixerNode). - The simple case has no lifecycle callbacks, methods the framework calls at moments like “the call started”.
- Configuration comes from the environment, or from the app’s manifest, its settings file.
- Imports come from a few known modules.
- The simple case doesn’t need
async.
None of these rules is right in general; plenty of good frameworks are async from the first line. They fit this framework because its users already know them. A proposal that breaks one asks users to learn a second way to do something they can already do.
The VoiceAgent snippet above breaks at least two of them: a second app class, and a lifecycle callback (on_call_started) just to say hello. The critic’s rewrite moved the greeting into the language-model step’s configuration instead, LLM(greeting="Hi! ..."). The same rules settle the first two rows of the opening figure. Two names for one job fail the test together, because the follow-up is “what’s the difference?”, and the naming rule says which way to go: the Node versions lose their suffix, and each job keeps one short name, STT and LLM in the rewrite.
Make the rules countable
Rules still invite a debate about whether something “feels consistent”, so the critic turned them into checks, the newspaper’s style checker:
| Check | Target | How to measure |
|---|---|---|
| New names | 2 or fewer | Classes, functions and decorators a user must import that the framework didn’t have |
| Lifecycle callbacks | None in the simple case | Methods the user must override |
| Import depth | 3 levels or fewer | Count the dotted parts (a.b.c is 3) |
async | Not in the simple case | Does the user need async def main()? |
| Configuration | Environment plus one manifest file | No config objects in the simple case |
| Lines | 15 or fewer for a minimal app | Count non-blank, non-comment lines |
The targets came from the framework’s own small examples, so treat them as starting points for yours. Anyone reading a proposal can check it against them.
The scaling proposal, checked
To run many sessions at once, one per phone call, the worker proposal turned the app into an async function that a Worker starts once for each session. This is the shape, from the reconstruction in the demo folder:
async def entrypoint(ctx: JobContext): call = ctx.metadata pipeline = ctx.pipeline ... # the endpoint and the steps, configured from call ctx.add_shutdown_callback(lambda: print(f"Call {call['call_id']} ended")) await ctx.session.run_until_complete()
if __name__ == "__main__": worker = Worker( WorkerOptions( entrypoint_fnc=entrypoint, max_concurrent=5, name="voice-agent", ) ) worker.run()entrypoint is the app now, and it gets everything about its session from ctx. Worker(WorkerOptions(...)) starts it once per session, up to five at a time. Against the checks, it fails three of the six just to scale:
| Check | The worker proposal |
|---|---|
| New names | 3: Worker, WorkerOptions, JobContext |
async | async def entrypoint, and an await |
| Configuration | a config object, WorkerOptions |
(Its shutdown callback is registered, not a method the user overrides, so it passes the lifecycle check as written. And lines don’t separate the two designs: the reconstruction is 34 lines, and the critic’s own app, rewrites/voice_agent.py, is 31, both over the target of 15 for this fuller app.)
The critic’s alternative left the app file as it was:
pipeline = Pipeline("voice-agent")... # the steps, with per-call details read from os.environSession(pipeline).run()and put the concurrency in the manifest:
...[app.worker]max_concurrent = 5
There’s still a worker in the critic’s sketch; the difference is who runs it. Deployment tooling reads the manifest, wraps the same app file in the worker, and passes each session’s details in, so the app author never imports Worker or JobContext. That tooling is part of the design, not something that exists yet. And if the details arrive as environment variables, each session needs its own process, because a process has one set of environment variables, which not every workload can afford. Either way, the app code doesn’t change, only the two lines of manifest above.
Where the borrowed names came from
The worker proposal took Worker, WorkerOptions(entrypoint_fnc=...) and JobContext from LiveKit Agents, LiveKit’s open-source agent framework and one of the projects I’d pointed the agents at. All three appear in its 1.0 releases, as does AgentSession, which three of the other proposals used. (LiveKit has since replaced Worker with AgentServer.) The proposal chose Worker because it “aligns with industry terminology from LiveKit, Celery, Temporal”. They’re good names in LiveKit, whose programming model is built around them. Next to Pipeline, >> and Session, they became a second programming model.
That’s the six freelancers again: each wrote in the style of the papers it had read before. I’d watch for this in any agent-written proposal. Each agent fits its design to the prior art it has read, and none of them checks the others, so give them your rules before the prior art.
Try it on your API
- List every public name a new user has to learn, and write one sentence for each. Mark the ones that invite a question. On an existing API, you can start here.
- Write down the house style. Take two or three apps or examples you already have, and list the rules they share. Before you invite proposals, from people or agents, hand these over.
- Turn the rules into checks someone can count, taking the targets from your smallest real app.
And lint or run the sample code, which none of these checks replaces. One proposal defined its tools as plain functions and still reached for self:
@toolasync def end_call() -> str: """End the current call.""" await self.session.hangup() return "Call ended"Python doesn’t run a function’s body until the tool actually runs, so a module like this loads without complaint and fails with a NameError the first time the model calls the tool. pyflakes, a linter, flags it without running anything:
proposals/voice_agent.py:3:1: 'mf_all.Session' imported but unusedproposals/voice_agent.py:3:1: 'mf_all.AgentSession' imported but unusedproposals/voice_agent.py:12:11: undefined name 'self'proposals/voice_agent.py:19:11: undefined name 'self'The last two lines are the bug, once in each of the proposal’s two tools; the first two are unused imports. Running the samples wasn’t an option anyway: every one shown here imported from mf_all, a package the agents made up, so only reading or linting could catch it.
The demo
The one-sentence-api/ folder has generic reconstructions of the voice-agent and worker proposals, the critic’s rewrites, and mf, a tiny stand-in framework that prints the pipeline and fakes one model turn, so the rewrite runs. It isn’t a media stack.
cd mainlog-code/one-sentence-apipython3 -m venv .venv && .venv/bin/pip install pyflakes==3.4.0.venv/bin/python -m pyflakes proposals/voice_agent.py # the undefined self, without running anything.venv/bin/python -c "import sys, asyncio; sys.path.insert(0, 'proposals'); import tool_as_proposed; asyncio.run(tool_as_proposed.transfer_call('+15550100'))"SIP_TRUNK=my-trunk CALLEE=+15551234567 PYTHONPATH=. .venv/bin/python rewrites/voice_agent.py.venv/bin/python count_lines.py proposals/worker.py rewrites/voice_agent.pyThe second command imports the proposal’s tool happily, then fails with NameError: name 'self' is not defined when it’s called. The third runs the critic’s rewrite against the stand-in:
[session] graph: sip.audio_in >> VAD >> STT >> LLM >> TTS >> sip.audio_out[agent] says: Hi! This is a reminder about your appointment tomorrow.[endpoint] transferring call to +15550100[agent] tool result: Transferred to +15550100The six proposals were sensible designs for their own corners of the framework. They clashed because I hadn’t given the agents a written definition of what fits, so each one brought its own from the prior art it had read. Write the house style down first, and the one-sentence test has something to measure against.
Related posts

What plugin discovery actually checks
A plugin you can see in pip list comes back “not found”. Python hosts turn installed entry points into loaded code in four checks, and the order of those checks decides whether a plugin's code runs before anyone asks if it fits, and whether the error your code catches says why.
The unwritten rulespythonpackagingplugins
How many concepts is too many
Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.
The unwritten rulespythonapi-designframeworks
Green on my laptop, red in CI
A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.
The unwritten rulespythontesting
Software craftWhat plugin discovery actually checks
A plugin you can see in pip list comes back “not found”. Python hosts turn installed entry points into loaded code in four checks, and the order of those checks decides whether a plugin's code runs before anyone asks if it fits, and whether the error your code catches says why.
pythonpackagingSep 2026
Software craftHow many concepts is too many
Framework debates trade concept counts that measure different things. Count what the smallest real program makes its user write, in separate columns and per reader, find the rules the count can't see by misspelling them, and keep an extra concept only when it moves a check earlier or gives users a seam they can see.
pythonapi-designOct 2026
Software craftGreen on my laptop, red in CI
A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.
pythontestingSep 2026
Comments