# A resumable, human-in-the-loop AI agent in ~200 lines with zero dependencies

> Source: <https://dev.to/rahul_choudhary_682e5ca00/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies-edg>
> Published: 2026-07-21 18:44:31+00:00

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](https://github.com/rahul1368/yieldagent):

a small agent loop you can read end to end, with human-in-the-loop pause/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:

``` js
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:

``` js
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:

``` js
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 pause 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`

:

``` js
const cfg = {
  call, tools,
  messages: [{ role: "user", content: "Email the Delhi weather to my boss" }],
  approve: (tool) => tool !== "sendEmail",
};

let paused;
for await (const step of agent(cfg)) {
  if (step.type === "paused") paused = 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:

``` js
import { resume } from "yieldagent";
for await (const step of resume(cfg, paused)) {
  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:

``` js
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 pause/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/](https://rahul1368.github.io/yieldagent/)

Code and docs: [https://github.com/rahul1368/yieldagent](https://github.com/rahul1368/yieldagent)

If you build something with it, or the pause/resume API breaks down for your use

case, I'd like to hear about it.
