{"slug": "a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies", "title": "A resumable, human-in-the-loop AI agent in ~200 lines with zero dependencies", "summary": "A developer created yieldagent, a minimal AI agent library in ~200 lines with zero dependencies, featuring human-in-the-loop pause/resume and testability without an LLM. The library exposes every step as a plain object via async generators, allowing callers to log, render, or assert on tool calls and results. It supports serializable resume states for pausing and resuming agent runs across different requests or restarts.", "body_md": "Most \"AI agent\" libraries fall into one of two buckets. Either they're a big\n\nframework you spend an afternoon configuring, or they're a tiny toy that drops\n\nthe one feature you actually need in production: the ability to stop and ask a\n\nhuman before the agent does something you can't undo.\n\nI wanted the middle. So I wrote [yieldagent](https://github.com/rahul1368/yieldagent):\n\na small agent loop you can read end to end, with human-in-the-loop pause/resume\n\nbuilt in, and no runtime dependencies. This post walks through how it works and\n\nwhy it's built the way it is.\n\nStrip away the branding and an \"agent\" is a loop:\n\nThat's it. The model decides the control flow at runtime; your job is to run the\n\ntools and feed the results back. Here's the core, lightly trimmed:\n\n``` js\nfor (let step = 0; step < maxSteps; step++) {\n  const reply = await call(messages, toolSpecs);\n  messages.push(reply);\n\n  if (!reply.tool_calls?.length) {\n    yield { type: \"final\", text: reply.content, messages };\n    return;\n  }\n\n  for (const tc of reply.tool_calls) {\n    const args = JSON.parse(tc.function.arguments);\n    const result = await tools[tc.function.name].run(args);\n    messages.push({ role: \"tool\", tool_call_id: tc.id, content: JSON.stringify(result) });\n  }\n}\n```\n\nEverything else in the library is in service of making this loop observable,\n\ntestable, and safe to run against the real world.\n\nNotice the `yield`\n\n. The loop is an async generator, so the caller drives it:\n\n``` js\nfor await (const step of agent({ call, tools, messages })) {\n  if (step.type === \"tool-start\") console.log(\"->\", step.tool, step.args);\n  if (step.type === \"final\") console.log(step.text);\n}\n```\n\nEvery step (each tool call, each result, and the final answer) is handed back to\n\nyou as a plain object. Nothing is hidden inside the framework. You can log it,\n\nrender it, or assert on it in a test. Which brings us to the nicest side effect.\n\nThe model call is just a function: `(messages, tools) => Promise<Message>`\n\n. In\n\ntests, you pass one that returns canned replies. No API key, no network, and the\n\nresult is deterministic:\n\n``` js\nconst replies = [\n  { role: \"assistant\", content: null, tool_calls: [{ id: \"1\", function: { name: \"getWeather\", arguments: '{\"city\":\"Delhi\"}' } }] },\n  { role: \"assistant\", content: \"It's 31°C.\", tool_calls: [] },\n];\nlet i = 0;\nconst call = async () => replies[i++];\n\nconst steps = [];\nfor await (const s of agent({ call, tools, messages })) steps.push(s);\n\nexpect(steps.map(s => s.type)).toEqual([\"tool-start\", \"tool-end\", \"final\"]);\n```\n\nThe whole library is tested this way. Agent logic you can unit-test without an\n\nLLM is worth a lot when you're iterating.\n\nReal agents do risky things, send emails, spend money, delete files. You want a\n\nhuman in the loop *before* that happens, and often you want to pause a run and\n\ncontinue it later, in a different request or after a restart.\n\nyieldagent does this with an `approve`\n\ncallback. Return `false`\n\nfor a tool and\n\nthe loop stops before running it, handing back a serializable `resumeState`\n\n:\n\n``` js\nconst cfg = {\n  call, tools,\n  messages: [{ role: \"user\", content: \"Email the Delhi weather to my boss\" }],\n  approve: (tool) => tool !== \"sendEmail\",\n};\n\nlet paused;\nfor await (const step of agent(cfg)) {\n  if (step.type === \"paused\") paused = step.resumeState; // a plain object\n  if (step.type === \"final\") console.log(step.text);\n}\n```\n\nBecause `resumeState`\n\nis just data, you can write it to a database or a job\n\nqueue, wait for a human to click \"approve\" somewhere else entirely, then pick up\n\nwhere you left off:\n\n``` js\nimport { resume } from \"yieldagent\";\nfor await (const step of resume(cfg, paused)) {\n  if (step.type === \"final\") console.log(step.text);\n}\n```\n\nThis is the part most minimal agents skip and most big frameworks turn into a\n\nwhole subsystem. Keeping it small and explicit was the main reason I wrote this.\n\nThe core knows nothing about any specific provider. The included adapter talks\n\nto anything that speaks the OpenAI `/chat/completions`\n\nshape, OpenAI, Anthropic's\n\ncompatible endpoint, Groq, Together, or a local model via Ollama or vLLM:\n\n``` js\nconst call = openaiCompatible({\n  baseURL: \"http://localhost:11434/v1\", // Ollama\n  apiKey: \"ollama\",\n  model: \"llama3.1\",\n});\n```\n\nOr skip the adapter and write your own `call`\n\n, it's about a dozen lines.\n\n`stream`\n\ninstead of `call`\n\nand you get `token`\n\nsteps as the\nmodel produces text. Tools and pause/resume still work.`yieldagent/zod`\n\nentry derives the JSON Schema from\na Zod schema and validates the model's arguments, feeding errors back so the\nmodel can correct itself.`AbortSignal`\n\nto stop a run on a timeout or user cancel.If you need streaming UI helpers, a big prebuilt tool ecosystem, or multi-agent\n\norchestration out of the box, reach for the Vercel AI SDK or LangGraph. yieldagent\n\nis for when you'd rather own a loop you can read in a few minutes than adopt a\n\nframework. If you outgrow it, you'll know exactly what you're replacing.\n\n```\nnpm install yieldagent\n```\n\nThere's a browser demo of the approval flow (no API key needed):\n\n[https://rahul1368.github.io/yieldagent/](https://rahul1368.github.io/yieldagent/)\n\nCode and docs: [https://github.com/rahul1368/yieldagent](https://github.com/rahul1368/yieldagent)\n\nIf you build something with it, or the pause/resume API breaks down for your use\n\ncase, I'd like to hear about it.", "url": "https://wpnews.pro/news/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies", "canonical_source": "https://dev.to/rahul_choudhary_682e5ca00/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies-edg", "published_at": "2026-07-21 18:44:31+00:00", "updated_at": "2026-07-21 19:21:34.337868+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": ["yieldagent", "Rahul"], "alternates": {"html": "https://wpnews.pro/news/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies", "markdown": "https://wpnews.pro/news/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies.md", "text": "https://wpnews.pro/news/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies.txt", "jsonld": "https://wpnews.pro/news/a-resumable-human-in-the-loop-ai-agent-in-200-lines-with-zero-dependencies.jsonld"}}