The tool-calling loop for LLM agents — iterator-first, protocol-hooked, zero runtime dependencies.
Project description
looplet
A small, framework-agnostic Python library for building LLM agents that call tools in a loop.
It hands you a for step in loop(...): iterator so you can observe, filter, or interrupt
any step — no graph DSL, no subclassing, no vendor lock-in. Zero runtime dependencies.
from looplet import composable_loop
for step in composable_loop(llm=llm, tools=tools, task=task, config=cfg, state=state):
print(step.pretty()) # → "#1 ✓ search(query='…') → 12 items [182ms]"
if step.tool_result.error:
break # your loop, your control flow
pip install looplet # core — zero third-party packages pulled in
pip install "looplet[openai]" # works with OpenAI, Ollama, Together, Groq, vLLM, …
pip install "looplet[anthropic]" # or Anthropic directly
Why it exists
Most agent frameworks give you agent.run(task) and a black box. When the
agent does something wrong at step 7, you can't step in between step 6 and
step 8. You end up forking the library or writing a second agent to babysit
the first.
looplet does the opposite: the loop is the whole product, and hooks are
the whole API. Every tool call is a Step object you can print, save, or
diff. Every decision the loop makes — what goes in the next prompt, whether
to compact context, whether to dispatch a dangerous tool, whether to stop —
is a Protocol method you implement in 3 lines. Hooks compose without
inheritance. Nothing is hidden.
That one design choice is where the library's three practical superpowers come from:
- Shape agent behaviour without forking — a 10-line hook can redact PII from every prompt, inject retrieved docs, rewrite tool arguments, or rate-limit calls to a single tool. Hooks are the extension point the framework can't close off because the loop itself is built on them.
- Manage context on your terms —
compact_chain(Prune, Summarize, Truncate)is three hooks you wire together. Swap the strategy, change the budget, fire on a different threshold — no monkey-patching. - Debug and eval without a second tool —
step.pretty()is a human-readable trace,ProvenanceSinkdumps every prompt the LLM saw plus every tool result into a diff-friendly directory, and pytest-styleeval_*functions turn that trace into a regression suite. Your debug output is your eval harness.
It's what you'd build if you wrote an agent once, got tired of fighting the framework, and decided the framework was the problem.
The mental model — one picture
looplet is a for-loop you own, three loop phases, and four
Protocol methods you can implement to change any part of the loop.
That's the whole thing:
%%{init: {"theme":"base","themeVariables":{
"fontFamily":"ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
"fontSize":"15px",
"lineColor":"#94a3b8"
}}}%%
flowchart LR
you(["<b>your code</b><br/><span style='font-size:11px;opacity:.75'>for step in loop(...)</span>"]):::you
h1["<b>pre_prompt</b><br/><span style='font-size:11px;opacity:.9'>redact · inject · compact</span>"]:::hookBlue
h2["<b>pre_dispatch</b><br/><span style='font-size:11px;opacity:.9'>permissions · approval · rewrite</span>"]:::hookAmber
h3["<b>post_dispatch</b><br/><span style='font-size:11px;opacity:.9'>trace · metrics · checkpoint</span>"]:::hookAmber
h4["<b>check_done</b><br/><span style='font-size:11px;opacity:.9'>stop rules · budgets</span>"]:::hookGreen
subgraph loop[" "]
direction LR
prompt(["<b>PROMPT</b><br/><span style='font-size:11px;opacity:.85'>build · call LLM</span>"]):::phaseBlue
dispatch(["<b>DISPATCH</b><br/><span style='font-size:11px;opacity:.85'>validate · run tool</span>"]):::phaseAmber
done{{"<b>DONE?</b>"}}:::phaseGreen
prompt -- "tool call" --> dispatch
dispatch -- "observation" --> done
done -- "no" --> prompt
end
step[/"<b>Step</b><br/><span style='font-size:11px;opacity:.85'>prompt · call · result · usage</span>"/]:::step
you == "task" ==> prompt
done == "yes" ==> step
step == "yield" ==> you
h1 -.-> prompt
h2 -.-> dispatch
h3 -.-> dispatch
h4 -.-> done
linkStyle 0,1,2 stroke:#94a3b8,stroke-width:2px
linkStyle 3 stroke:#475569,stroke-width:3px
linkStyle 4 stroke:#059669,stroke-width:3px
linkStyle 5 stroke:#475569,stroke-width:3px
linkStyle 6 stroke:#3b82f6,stroke-width:1.5px
linkStyle 7 stroke:#f59e0b,stroke-width:1.5px
linkStyle 8 stroke:#f59e0b,stroke-width:1.5px
linkStyle 9 stroke:#10b981,stroke-width:1.5px
classDef you fill:#0f172a,stroke:#334155,stroke-width:2px,color:#f8fafc;
classDef phaseBlue fill:#dbeafe,stroke:#2563eb,stroke-width:2.5px,color:#1e3a8a;
classDef phaseAmber fill:#fef3c7,stroke:#d97706,stroke-width:2.5px,color:#78350f;
classDef phaseGreen fill:#d1fae5,stroke:#059669,stroke-width:2.5px,color:#064e3b;
classDef step fill:#eef2ff,stroke:#4338ca,stroke-width:2.5px,color:#312e81;
classDef hookBlue fill:#eff6ff,stroke:#3b82f6,stroke-width:1.5px,color:#1e40af;
classDef hookAmber fill:#fffbeb,stroke:#f59e0b,stroke-width:1.5px,color:#92400e;
classDef hookGreen fill:#ecfdf5,stroke:#10b981,stroke-width:1.5px,color:#065f46;
style loop fill:#f8fafc,stroke:#cbd5e1,stroke-width:2px,stroke-dasharray:10 6,color:#1e293b;
Every amber box is a Protocol method. A hook is any object that
implements one or more of them — no base class, no inheritance:
class RedactPII:
def pre_prompt(self, state, log, ctx, step):
return _scrub_emails(ctx) # mutates the next LLM prompt
class RetryFlakyTool:
def pre_dispatch(self, state, log, tc, step):
if tc.tool == "web_search" and state.last_error:
return Deny("retry with backoff", retry=True)
for step in composable_loop(..., hooks=[RedactPII(), RetryFlakyTool()]):
...
Ship-ready hooks already wired in: ApprovalHook, PermissionHook,
CheckpointHook, ContextPressureHook, ThresholdCompactHook,
ProvenanceSink, TracingHook, MetricsHook, EvalHook, plus the
compact_chain(Prune, Summarize, Truncate) context strategy. Use any,
all, or none — and drop in your own in 10 lines.
Your first agent (60 seconds)
from looplet import (
BaseToolRegistry, DefaultState, LoopConfig, OpenAIBackend,
ToolSpec, composable_loop, register_done_tool,
)
import os
llm = OpenAIBackend(
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
api_key=os.environ.get("OPENAI_API_KEY", ""),
model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
)
tools = BaseToolRegistry()
tools.register(ToolSpec(
name="greet", description="Greet someone.",
parameters={"name": "str"},
execute=lambda *, name: {"greeting": f"Hello, {name}!"},
))
register_done_tool(tools)
for step in composable_loop(
llm=llm, tools=tools,
state=DefaultState(max_steps=5),
config=LoopConfig(max_steps=5),
task={"goal": "Greet Alice and Bob, then finish."},
):
print(step.pretty())
Works out of the box with any OpenAI-compatible endpoint. No Claude-only SDK, no pydantic schema gymnastics, no LangChain memory objects.
Try it on your laptop against a local Ollama in three lines:
OPENAI_BASE_URL=http://127.0.0.1:11434/v1 \
OPENAI_API_KEY=ollama OPENAI_MODEL=llama3.1 \
python -m looplet.examples.hello_world
When should you reach for looplet?
Use it when you want to build your own agent loop and actually own the details. Concretely:
- You need to insert logic at an exact phase of the loop — before the prompt is built, before a tool is dispatched, after a tool returns — without forking a framework.
- You need to swap context-management strategy at runtime (prune, summarize, truncate, your own) without losing the rest of your stack.
- You need the loop to pause for human approval, then resume where it left off when approval arrives.
- You want first-class debugging and evaluation — a printable
Step, a prompt-level provenance dump, pytest-styleeval_*functions — without bolting on a second tool. - You want zero runtime dependencies and a loop that cold-imports in ~300 ms (numbers in docs/benchmarks.md).
Don't reach for looplet if you want agent.run(task) to handle
everything and return a string, or if you want a visual graph DSL — a
higher-level framework will feel more natural and the overlap in
features won't be worth the extra control looplet gives you.
Examples
All three real-LLM examples read OPENAI_BASE_URL, OPENAI_API_KEY, and
OPENAI_MODEL from the environment. Point them at Ollama or any
OpenAI-compatible endpoint.
python -m looplet.examples.hello_world # 30-line starter
python -m looplet.examples.coding_agent "implement fizzbuzz" # bash/read/write/edit/grep
python -m looplet.examples.coding_agent --trace ./traces/ # save full trajectory
python -m looplet.examples.data_agent --clean # approval + compact + checkpoints
python -m looplet.examples.data_agent --resume # resume from last checkpoint
Plus scripted_demo.py —
a scripted MockLLMBackend run used only to record the GIF above.
Not a usage reference.
Learn more
| Doc | What's in it |
|---|---|
| docs/tutorial.md | Build your first agent in 5 steps |
| docs/hooks.md | Writing and composing hooks |
| docs/evals.md | pytest-style agent evaluation |
| docs/provenance.md | Capturing prompts + trajectories |
| docs/recipes.md | Ollama, OTel, MCP, cost accounting, checkpoints |
| docs/benchmarks.md | Cold-import time & dep footprint vs alternatives |
| docs/faq.md | FAQ, including "why not LangGraph?" |
| ROADMAP.md | What's planned, what's frozen, what's out of scope |
| CONTRIBUTING.md | Dev setup, conventions, PR checklist |
| CHANGELOG.md | Release notes |
Stability
looplet follows SemVer. Pre-1.0, minor versions
may introduce breaking changes as the design stabilises — pin conservatively:
looplet>=0.1.7,<0.2
See ROADMAP.md § v1.0 API contract for the
frozen surface and the path to 1.0.
Contributors
Thanks to everyone who has contributed to looplet:
- @mvanhorn - "Why not LangGraph?" FAQ (#17)
See CONTRIBUTING.md for how to get started.
Contributing
Contributions welcome: bug reports, docs, backends, examples, evals. Start with CONTRIBUTING.md and docs/good-first-issues.md. Security issues go through SECURITY.md.
License
Apache 2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file looplet-0.1.8.tar.gz.
File metadata
- Download URL: looplet-0.1.8.tar.gz
- Upload date:
- Size: 303.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aab3ca41ce78136ba2a84d08a6d5529f3543f43f3372f0a961cd1472188fe6c9
|
|
| MD5 |
fb05a3c585dd1000433ac954d39a7061
|
|
| BLAKE2b-256 |
edc2b4d0d90fa02936f0bd65e89e9b3175979afdc3869a3a6d7b7bc7dbf19a9d
|
Provenance
The following attestation bundles were made for looplet-0.1.8.tar.gz:
Publisher:
publish.yml on hsaghir/looplet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
looplet-0.1.8.tar.gz -
Subject digest:
aab3ca41ce78136ba2a84d08a6d5529f3543f43f3372f0a961cd1472188fe6c9 - Sigstore transparency entry: 1368131244
- Sigstore integration time:
-
Permalink:
hsaghir/looplet@8ea2bf5626bcee78196eeba1e42cff698429d38a -
Branch / Tag:
refs/tags/v0.1.8 - Owner: https://github.com/hsaghir
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8ea2bf5626bcee78196eeba1e42cff698429d38a -
Trigger Event:
push
-
Statement type:
File details
Details for the file looplet-0.1.8-py3-none-any.whl.
File metadata
- Download URL: looplet-0.1.8-py3-none-any.whl
- Upload date:
- Size: 204.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c52429a53bc36a3d098d459c65f53e571ffbb91483251fcce51ab95cbec53b5
|
|
| MD5 |
c9a7af45789dfe2c5bcda1d928749437
|
|
| BLAKE2b-256 |
cc6310073a212493e2f642936e273022d8668ccd231ea57bff56c9e82a7c2ff8
|
Provenance
The following attestation bundles were made for looplet-0.1.8-py3-none-any.whl:
Publisher:
publish.yml on hsaghir/looplet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
looplet-0.1.8-py3-none-any.whl -
Subject digest:
2c52429a53bc36a3d098d459c65f53e571ffbb91483251fcce51ab95cbec53b5 - Sigstore transparency entry: 1368131331
- Sigstore integration time:
-
Permalink:
hsaghir/looplet@8ea2bf5626bcee78196eeba1e42cff698429d38a -
Branch / Tag:
refs/tags/v0.1.8 - Owner: https://github.com/hsaghir
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8ea2bf5626bcee78196eeba1e42cff698429d38a -
Trigger Event:
push
-
Statement type: