Software craft The unwritten rules

The one-sentence API design test

Every public name gets one sentence. If a colleague would ask a follow-up, the name fails.

On this page · 7 sections

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.

Four rows compare the six proposals. A speech-to-text step is called STTNode or SpeechToText; a language-model step LLMNode or LLMProcessor. An app can already start with Session(...).run() or from the framework's app class; the proposals added a VoiceAgent subclass and Worker(...).run(). AgentSession reads as a step, an orchestrator, or a kind of Session.
Where the six designs disagreed. Green is what the framework already had.

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:

NameOne sentence, or the follow-up it getsResult
PipelineThe container for your chain of steps.✅
SessionRuns your pipeline until it’s done.✅
>>Connects two steps; data flows left to right.✅
@toolMakes 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:

python
agent = AgentSession(model=..., tools=[...])
pipeline = Pipeline(...)
ep.audio_in >> agent >> ep.audio_out
Session(pipeline).run()

Another kept it inside a new app base class, where it runs the conversation from outside the pipeline:

python
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.

python
ep.audio_in >> RealtimeModel(model=..., tools=[...]) >> ep.audio_out

When a name fails, work down this chart and stop at the first yes:

A flowchart for a public name, with the critic's calls on the six proposals as examples. Can you explain it in one sentence without a follow-up question? Yes: keep it. Otherwise: if it is about how the app is run, not what it does, move it into configuration, as with Worker and JobContext. If the framework already has something for it, use the existing name, as with VoiceAgent and the app class. If it does two jobs within one design, split it into one name and one sentence each. Otherwise pick one meaning and rename or redefine it until one sentence is true, as the critic did in renaming AgentSession to RealtimeModel, a step in the pipeline.
Each failed name from the table lands on one of the right-hand boxes.
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:

python
import os
pipeline = Pipeline("multiparty")
ep = Endpoint.webrtc(signaling_url=os.environ["SIGNALING_URL"])
ep.audio_in >> OpusDecoder() >> AudioMixer() >> OpusEncoder() >> ep.audio_out
Session(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 calling run().
  • 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, not AudioMixerNode).
  • 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:

CheckTargetHow to measure
New names2 or fewerClasses, functions and decorators a user must import that the framework didn’t have
Lifecycle callbacksNone in the simple caseMethods the user must override
Import depth3 levels or fewerCount the dotted parts (a.b.c is 3)
asyncNot in the simple caseDoes the user need async def main()?
ConfigurationEnvironment plus one manifest fileNo config objects in the simple case
Lines15 or fewer for a minimal appCount 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:

python
proposals/worker.py
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:

CheckThe worker proposal
New names3: Worker, WorkerOptions, JobContext
asyncasync def entrypoint, and an await
Configurationa 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:

python
pipeline = Pipeline("voice-agent")
... # the steps, with per-call details read from os.environ
Session(pipeline).run()

and put the concurrency in the manifest:

toml
rewrites/app.toml
...
[app.worker]
max_concurrent = 5
Two panels. In the worker proposal, the author's code holds an async entrypoint that takes a JobContext and a Worker built from WorkerOptions, so dispatch, concurrency, and each session's details all live in the author's code. In the critic's sketch, the author's code is the unchanged app file plus a manifest setting of five concurrent sessions; deployment tooling reads the manifest, wraps the same app file in the worker, and passes each session's details in through the environment or config.
Where scaling lives: in the app author’s code, or in deployment tooling that reads a setting.

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#

  1. 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.
  2. 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.
  3. 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:

python
proposals/voice_agent.py
@tool
async 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:

text
python -m pyflakes proposals/voice_agent.py
proposals/voice_agent.py:3:1: 'mf_all.Session' imported but unused
proposals/voice_agent.py:3:1: 'mf_all.AgentSession' imported but unused
proposals/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.

Shell
Terminal window
cd mainlog-code/one-sentence-api
python3 -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.py

The 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:

text
[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 +15550100

The 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.

Share this article

Related posts

3 items
  • Sep 4, 2026 18 min read Software craft

    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
  • Oct 2, 2026 16 min read Software craft

    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
  • Sep 15, 2026 11 min read Software craft

    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 craft

    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.

    pythonpackaging
    Sep 2026
  • Software craft

    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.

    pythonapi-design
    Oct 2026
  • Software craft

    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.

    pythontesting
    Sep 2026

Comments