Most "AI agent" libraries fall into one of two buckets. Either they're a big
framework you spend an afternoon configuring, or they're a tiny toy that drops
the one feature you actually need in production: the ability to stop and ask a
human before the agent does something you can't undo.
I wanted the middle. So I wrote yieldagent:
a small agent loop you can read end to end, with human-in-the-loop /resume
built in, and no runtime dependencies. This post walks through how it works and
why it's built the way it is.
Strip away the branding and an "agent" is a loop:
That's it. The model decides the control flow at runtime; your job is to run the
tools and feed the results back. Here's the core, lightly trimmed:
for (let step = 0; step < maxSteps; step++) {
const reply = await call(messages, toolSpecs);
messages.push(reply);
if (!reply.tool_calls?.length) {
yield { type: "final", text: reply.content, messages };
return;
}
for (const tc of reply.tool_calls) {
const args = JSON.parse(tc.function.arguments);
const result = await tools[tc.function.name].run(args);
messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(result) });
}
}
Everything else in the library is in service of making this loop observable,
testable, and safe to run against the real world.
Notice the yield
. The loop is an async generator, so the caller drives it:
for await (const step of agent({ call, tools, messages })) {
if (step.type === "tool-start") console.log("->", step.tool, step.args);
if (step.type === "final") console.log(step.text);
}
Every step (each tool call, each result, and the final answer) is handed back to
you as a plain object. Nothing is hidden inside the framework. You can log it,
render it, or assert on it in a test. Which brings us to the nicest side effect.
The model call is just a function: (messages, tools) => Promise<Message>
. In
tests, you pass one that returns canned replies. No API key, no network, and the
result is deterministic:
const replies = [
{ role: "assistant", content: null, tool_calls: [{ id: "1", function: { name: "getWeather", arguments: '{"city":"Delhi"}' } }] },
{ role: "assistant", content: "It's 31°C.", tool_calls: [] },
];
let i = 0;
const call = async () => replies[i++];
const steps = [];
for await (const s of agent({ call, tools, messages })) steps.push(s);
expect(steps.map(s => s.type)).toEqual(["tool-start", "tool-end", "final"]);
The whole library is tested this way. Agent logic you can unit-test without an
LLM is worth a lot when you're iterating.
Real agents do risky things, send emails, spend money, delete files. You want a
human in the loop before that happens, and often you want to a run and
continue it later, in a different request or after a restart.
yieldagent does this with an approve
callback. Return false
for a tool and
the loop stops before running it, handing back a serializable resumeState
:
const cfg = {
call, tools,
messages: [{ role: "user", content: "Email the Delhi weather to my boss" }],
approve: (tool) => tool !== "sendEmail",
};
let d;
for await (const step of agent(cfg)) {
if (step.type === "d") d = step.resumeState; // a plain object
if (step.type === "final") console.log(step.text);
}
Because resumeState
is just data, you can write it to a database or a job
queue, wait for a human to click "approve" somewhere else entirely, then pick up
where you left off:
import { resume } from "yieldagent";
for await (const step of resume(cfg, d)) {
if (step.type === "final") console.log(step.text);
}
This is the part most minimal agents skip and most big frameworks turn into a
whole subsystem. Keeping it small and explicit was the main reason I wrote this.
The core knows nothing about any specific provider. The included adapter talks
to anything that speaks the OpenAI /chat/completions
shape, OpenAI, Anthropic's
compatible endpoint, Groq, Together, or a local model via Ollama or vLLM:
const call = openaiCompatible({
baseURL: "http://localhost:11434/v1", // Ollama
apiKey: "ollama",
model: "llama3.1",
});
Or skip the adapter and write your own call
, it's about a dozen lines.
stream
instead of call
and you get token
steps as the
model produces text. Tools and /resume still work.yieldagent/zod
entry derives the JSON Schema from
a Zod schema and validates the model's arguments, feeding errors back so the
model can correct itself.AbortSignal
to stop a run on a timeout or user cancel.If you need streaming UI helpers, a big prebuilt tool ecosystem, or multi-agent
orchestration out of the box, reach for the Vercel AI SDK or LangGraph. yieldagent
is for when you'd rather own a loop you can read in a few minutes than adopt a
framework. If you outgrow it, you'll know exactly what you're replacing.
npm install yieldagent
There's a browser demo of the approval flow (no API key needed):
https://rahul1368.github.io/yieldagent/
Code and docs: https://github.com/rahul1368/yieldagent
If you build something with it, or the /resume API breaks down for your use
case, I'd like to hear about it.