lukeed

APIs for two kinds of readers

Name

agent-apis — apis for two kinds of readers

Synopsis

Explicit names, few options, no magic, and errors that say what to do next. An API that is good for agents turns out to be good for people too.

Filed

· 1,394 words · 13 min

Description

Most of the code that calls my libraries is now written by agents.

I do not mean that as a complaint. It changed what I look for when I design an API. A person learns an API once and then works from memory. An agent comes to it fresh every time, with only what fits in its context: the README, the types, maybe the source, and whatever it remembers from training. It does not remember that you told it last week that options.mode should almost always be "strict". It reads the signature and makes a guess.

So the question I ask now is: if a careful reader saw only the call site and the types, would it write the right code?

The good news is that this is the same question good API design always asked. Agents did not invent new rules. They made the old ones harder to ignore.

This post is about the shape of an API. The docs side, writing the usage block before the code, is in Write the README first.

#Names that say what they do

An agent guesses meaning from names. So does a person, but a person also reads the docs when the guess fails. An agent often does not find out that the guess failed until a test fails, or until nothing fails and the bug ships.

The clearest case I have is my own. empathic had an option called stop, and it was exclusive: the stop directory was not searched. The JSDoc said so in capital letters. A person still read { stop: '~' } as "search up to and including home", because that is what the word means to most people. The fix was to rename it to last and make it inclusive. Now the name and the behavior agree, and no note is needed. (The full story is here.)

A few habits I keep:

  • Verb and direction. find.up, walk.up, pkg.up. The call says what it does and which way it goes.
  • Names from the caller's side. last is the last directory the caller wants searched. stop was the place the loop stopped, which is the implementation's point of view.
  • No overloaded words. If limit could mean a count or a path, it is the wrong word for either.

Agents punish ambiguous names because they never ask a clarifying question. They pick the most common meaning of the word and move on.

#Fewer options, and choices you can see

Every option is a branch the reader has to know about. A person learns the important ones and ignores the rest. An agent sees all of them in the types and often sets several "to be safe".

My preferred way to offer a choice is not an option at all. It is a separate entry point.

import { dequal } from "dequal";
import { dequal } from "dequal/lite";

import { clsx } from "clsx";
import { clsx } from "clsx/lite";

dequal/lite skips Set, Map, ArrayBuffer, and typed arrays, and is 304 bytes. clsx/lite accepts only strings, and is 140 bytes. There is no { lite: true } flag that changes behavior from deep inside a function. The choice is in the import line, where a reader sees it first, and where a bundler can drop the code you did not choose.

empathic goes further. It is five small modules (access, find, package, resolve, and walk) instead of one module with modes:

import * as find from "empathic/find";
import * as pkg from "empathic/package";

An agent that imports empathic/find has a small, closed set of functions in front of it. It cannot pick the wrong mode, because there are no modes.

When a library truly needs options, I try to keep each one independent. Options that only make sense together, or that change what other options mean, are where both people and agents write code that type-checks and is still wrong.

#No magic: return plain data

Magic is behavior that the call site does not show. Hidden globals, objects that change shape after you create them, methods added by a plugin you did not import. It is convenient once you know about it. It is a trap for anyone who reads code instead of remembering it, and agents only read.

tschema is a good example of the opposite. Its builders return plain JSON Schema:

import * as t from "tschema";

let User = t.object({
  uid: t.integer(),
  name: t.string({ description: "full name" }),
});

console.log(User);
//-> {
//->   type: "object",
//->   properties: {
//->     uid: { type: "integer" },
//->     name: { description: "full name", type: "string" }
//->   },
//->   additionalProperties: false,
//->   required: ["uid", "name"]
//-> }

There is no class instance with private state. What you console.log is the whole value. An agent can print it, read it, compare it, and pass it to any other JSON Schema tool. The TypeScript type comes from t.Infer<typeof User>, which is also visible at the call site.

comptime follows the same idea from the build side: one import, two plugins, and no new syntax. A reader who has never heard of it still sees a normal function call.

#Errors that say what to do next

When a person hits an error, they read it, search for it, and maybe open an issue. When an agent hits an error, the message is often the only input it gets before its next attempt. A vague error makes it guess. A precise one is a free fix.

I want every error to answer three questions: where, what, and what now.

bongo, the static site generator behind this site, tries to do this. A bad frontmatter block gives:

content/blog/post.md: Invalid frontmatter: duplicate key "title" at line 3

The file, the problem, and the line. Some errors also include the fix:

Custom `url` is not allowed on output files; rename the file instead

That second half, "rename the file instead", saves an agent one or two wrong attempts. It saves a person a trip to the docs.

There is another way to make an error useful: give the caller the whole thing. fetch-event-stream has a stream() helper that throws the Response itself when the status is not 2xx:

try {
  let events = await stream(url, init);
} catch (res) {
  // res is the Response: read res.status, res.json(), ...
}

I do not wrap it in a custom error class with a summary message. The caller already knows how to read a Response, and so does every agent. The README says this in bold, because it is the one surprise in the API.

#Small enough to read whole

An agent has a context window. A person has an attention span. Both work better when the thing they depend on fits.

fetch-event-stream is 741 bytes. When an agent is not sure how it handles a reconnect or an empty line, it can read the whole source in less space than a long error log. It does not need to guess from the README, and it does not need training data from the right version. The source is the answer, and it is short.

I already wrote about why I like small libraries in Small on purpose. This is one more reason I did not see coming: a library that fits in a context window is a library an agent can check instead of guess.

#Writing for agents directly

APIs are not the only interface agents read. This site has an AGENTS.md, and writing it taught me the same lessons from the other side.

The most useful lines in it do not only give a rule. They give the reason, in one sentence:

Every animation loop uses pace.every, which sleeps between frames; a loop that requests a frame on each display refresh costs a render pass 120 times a second.

A rule without a reason works only for the cases it names. With the reason, an agent can handle a new case correctly, because it knows what the rule protects. The same file says to test performance in the installed Chrome, and says why: the bundled Chromium uses a software GPU and turns off the back-forward cache, so it hides the very bugs you are looking for.

That is the same thing a good error message does. Where, what, and why, so the next step is obvious.

#Good for agents is good for people

I started out thinking I would need to design for two audiences. I mostly did not. Every change I made for agents also helped people:

  • Clear names helped the person who skims.
  • Separate entry points helped bundle size.
  • Plain data helped debugging.
  • Errors with a next step helped everyone.
  • Small source helped the person who wanted to know how it works.

The difference is in how much a bad decision costs. A person forgives a confusing name after the first mistake and remembers it. An agent makes the same mistake every time, in every codebase, at scale.

So I design for the reader who remembers nothing. It turns out that is most of us, most of the time.

See also

api-design(7), agents(7), javascript(7), open-source(7), lukeed(1)