A lightweight async tool-calling agent for any OpenAI-compatible API, usable as a library or CLI
Project description
coreloop
A lightweight async tool-calling agent for any OpenAI-compatible API (via httpx).
The core is an async generator loop that streams Message objects; you observe and
intercept it via lifecycle hooks. Usable as a library or through a minimal CLI.
The minimal core imposes no forced overhead, so it pairs well with small local
models; early exit via hooks and stop() makes high-throughput batch work fast and
keeps token costs down.
Built-in tools: path-scoped read, ls, edit, grep; a bash tool with
best-effort guardrails (not a security sandbox — see below); optional web_search
and web_fetch (via the [web] extra).
- Observability — hook into every stage of the loop: before/after each turn, LLM call, and tool execution — examples: streaming · raw message stream · logging & timing hooks
- Loop control — stop cleanly, abort immediately, or inject responses mid-run:
stop(),abort(),on_before_llm— examples: lifecycle control · intercept & replace results - Extensibility — register custom async tools with
@tool: inferred JSON Schema, callable directly or by name — examples: custom tools · subagents
Install
pip install coreloop
pip install "coreloop[web]" # adds web_search and web_fetch
Library quick-start
import asyncio
from coreloop import Agent, Message
agent = Agent(
model="gpt-4o-mini",
base_url="https://api.openai.com/v1",
api_key="sk-...",
tools=["read", "ls", "grep"],
root="/tmp/sandbox",
)
async def main():
async for msg in agent.run([Message(role="user", content="What files are here?")]):
if msg.role == "assistant" and not msg.partial and msg.content:
print(msg.content)
asyncio.run(main())
Or load settings from a named profile in ~/coreloop.toml:
agent = Agent.from_profile("openai")
CLI
core is a REPL / one-shot runner with profile support. On first run it copies
the bundled coreloop.toml to ~/coreloop.toml — edit that file to set
your default model, tools, and provider credentials.
# Interactive REPL using the default profile (Ollama)
core
# One-shot with a named profile
core --profile openai -p "Summarise this repo"
# Override model and enable file tools
core --profile openai --model gpt-4o --tools read,ls,grep --root .
# Bypass profiles entirely
core --base-url https://api.openai.com/v1 --api-key $OPENAI_API_KEY --model gpt-4o-mini -p "Hello"
# Enable reasoning
core --think -p "Explain this step by step" --model qwen3-14b
| Flag | Default | Description |
|---|---|---|
--profile |
default |
Named profile from ~/coreloop.toml |
-m, --model |
profile value | Model name — overrides profile (CORELOOP_MODEL) |
--base-url |
profile value | API base URL — overrides profile (CORELOOP_BASE_URL) |
--api-key |
profile value | API key — overrides profile (CORELOOP_API_KEY) |
-s, --system |
— | System prompt |
--tools |
profile value | Comma-separated: read,ls,edit,grep,bash,web_search,web_fetch |
-r, --root |
cwd | Allowed root directory for file tools |
--searxng-url |
$SEARXNG_URL |
SearXNG base URL for web tools |
-t, --llm-timeout |
profile value | Asyncio wall-clock timeout per LLM turn (seconds) |
--tool-timeout |
profile value | Hard timeout per tool call (seconds) |
--http-request-timeout |
profile value | httpx per-chunk read timeout (seconds) |
--cache-dir |
profile value | LLM response cache directory |
--no-cache |
off | Disable response caching |
-e, --extra |
— | Extra JSON merged into the API request body |
--think/--no-think |
off | Set reasoning_effort to medium / none |
-n, --max-turns |
20 |
Maximum loop iterations |
-p, --prompt |
— | Run once and print final response |
--json |
off | Output all non-partial messages as JSONL |
-l, --log-level |
— | DEBUG, INFO, WARNING, ERROR |
REPL commands: /quit /exit /q to exit; /new to clear history;
/model <name> to switch models; /root <path> to change the file-tool root.
Agent
Agent(
model: str,
base_url: str = "http://localhost:11434/v1",
api_key: str | None = None,
system: str | None = None,
tools: list[str | ToolInfo] | None = None,
root: str | Path | None = None,
http_request_timeout: float = 300.0, # httpx per-chunk read timeout
tool_timeout: float = 360.0, # hard wall per tool call
llm_timeout: float = 300.0, # asyncio wall for the entire LLM turn
hooks: AgentHooks | None = None,
llm_extra_body: dict | None = None,
cache_dir: Path | str | None = "~/.cache/coreloop-llm-cache",
)
tools accepts built-in names ("read", "ls", "edit", "grep", "bash",
"web_search", "web_fetch"), names of @tool-registered functions, or ToolInfo
objects. File tools are scoped to root; an unknown name raises ValueError. An agent
has exactly the tools you list — there is no implicit inclusion of the global registry.
run(messages) is an async generator. Partial streaming chunks have partial=True;
the final assembled message for each LLM turn has partial=False. Pass
usage=Usage() to accumulate token counts across turns.
| Method / property | Description |
|---|---|
run(messages, *, usage=None) |
Run the agent loop, yielding Message objects |
stop() |
Finish the current turn cleanly, then exit. Safe from a hook or tool. |
abort() |
Cancel immediately; on_after_agent is not called |
reset() |
Clear history and stop flag |
stopped |
True after stop() or abort() |
messages |
Shallow copy of full chat history from the last run() |
Restart pattern — pass agent.messages to keep history across runs:
async for msg in agent.run([Message(role="user", content="Hello")]):
...
agent.model = "stronger-model"
async for msg in agent.run(agent.messages + [Message(role="user", content="Now do X")]):
...
Built-in tools
All file tools reject path traversal and are scoped to root.
| Tool | Description |
|---|---|
read |
Read a text file; offset/limit for paging (1-based line numbers) |
ls |
List a directory |
edit |
Replace an exact string in a file; fails on ambiguous matches |
grep |
Regex search via rg; supports type, after_context, files_with_matches |
bash |
Run arbitrary shell commands via bash -c; kills the entire process group on timeout |
bashis not sandboxed. It runs whatever the model sends with your full user privileges. Theworkdiris scoped toroot, but a command can still read, write, or delete anything your account can reach, and make network calls. The dangerous-pattern blocklist (e.g.rm -rf /,mkfs, fork bombs) is a speed bump against obvious accidents, not a security boundary — trivial variants slip through (curl … | bash,python -c "…", unusual flag orders). Only enablebashfor models and prompts you trust, and prefer running in a container or VM.
Web tools require pip install "coreloop[web]" and a running
SearXNG instance:
docker run -d -p 8080:8080 searxng/searxng
| Tool | Description |
|---|---|
web_search |
Search via SearXNG; returns titles, URLs, snippets; supports max_results, domain_filter, recency |
web_fetch |
Fetch a URL; extract_mode: markdown (default), article, raw, metadata |
Custom tools
from coreloop import tool
@tool
async def read_env(name: str) -> str:
"""Read an environment variable."""
import os
return os.environ.get(name, "(not set)")
# Attach by name or pass the ToolInfo object directly
agent = Agent(model="...", tools=["read_env"])
agent = Agent(model="...", tools=[read_env]) # equivalent
@tool infers JSON Schema from type annotations (str, int, float, bool,
list[T], dict, Optional[T]). Unrecognised types fall back to string with a
warning. Override name or description:
@tool(name="my_read", description="Read a local file")
async def _impl(path: str) -> str:
...
@tool(allow_override=True) # re-register if a tool with that name already exists
async def read_env(name: str) -> str:
...
The decorated object is both a ToolInfo and directly callable (await my_tool(...)
works alongside registration).
Registry helpers:
from coreloop import list_tools, get_tool, clear_registry
list_tools() # list[ToolInfo] — all globally registered tools
get_tool("read_env") # ToolInfo | None
clear_registry() # remove all tools (useful in tests)
Hooks
Subclass AgentHooks to observe or intercept any stage of the loop:
from coreloop import AgentHooks
class LogHook(AgentHooks):
async def on_before_tool(self, agent, name, args):
print(f"→ {name}({args})")
return None # None = run the tool; return str to inject a result instead
agent = Agent(..., hooks=LogHook())
Hook firing order per turn:
on_before_turn
on_before_llm → return Message to skip the LLM call entirely
<LLM streams>
on_after_llm → return Message to replace before appending to history
for each tool (in parallel):
on_before_tool → return str to skip tool execution
<tool runs>
on_after_tool → return str to replace result in history
on_after_turn
on_before_agent / on_after_agent bracket the entire run() call.
on_after_agent is not called after abort(). Hook exceptions are logged and
swallowed — they cannot crash the agent.
MaxTurnsHook(n) calls agent.stop() after n turns; the counter resets on each
run(), so it enforces a per-run limit, not a lifetime one.
Config profiles
Agent.from_profile("openai") builds an AgentConfig from ~/coreloop.toml
(or $CORELOOP_CONFIG). Every profile inherits from [profiles.default]; named
profiles override individual keys.
[profiles.default]
base_url = "http://localhost:11434/v1"
model = "qwen3.5:9b"
[profiles.openai]
base_url = "https://api.openai.com/v1"
api_key = "{{OPENAI_API_KEY}}"
model = "gpt-4o-mini"
tools = ["read", "ls", "grep"]
[profiles.together]
base_url = "https://api.together.xyz/v1"
api_key = "{{TOGETHER_API_KEY}}"
{{VAR_NAME}} in any string value is interpolated from the environment. Unknown keys
are silently ignored. The shipped src/coreloop/coreloop.toml includes
pre-configured profiles for Ollama, OpenAI, Groq, DeepSeek, Together, and OpenRouter.
AgentConfig is a dataclass mirroring the Agent constructor (excluding hooks). Use
dataclasses.replace(cfg, model="other") to derive variants, or Agent.from_config(cfg)
to construct an agent from one.
Message
| Field | Type | Description |
|---|---|---|
role |
str |
"user", "assistant", "system", "tool" |
content |
str | None |
Text content; None when tool_calls is set |
tool_calls |
list[ToolCall] | None |
Tool calls emitted by the assistant |
tool_call_id |
str | None |
Links a "tool" message to its call |
name |
str | None |
Tool name on role="tool" messages |
reasoning |
str | None |
Thinking-model scratchpad (Qwen3, DeepSeek). Not sent back to the API. |
partial |
bool |
True for streaming delta chunks |
usage |
Usage | None |
Token counts from the model |
model |
str | None |
Model name reported by the API |
duration |
float | None |
Seconds elapsed for the LLM turn or tool call |
timestamp |
datetime |
UTC creation time |
Caching
LLM responses are disk-cached by default (~/.cache/coreloop-llm-cache/), keyed
by SHA-256 of (model, messages, tools, extra_body). This replays identical requests
without hitting the API — useful during development. Pass cache_dir=None to disable.
agent = Agent(model="...", cache_dir="/tmp/my-cache") # custom location
agent = Agent(model="...", cache_dir=None) # disabled
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 coreloop-0.1.2.tar.gz.
File metadata
- Download URL: coreloop-0.1.2.tar.gz
- Upload date:
- Size: 35.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ca43e5a4189aea04e7eb4cb77b3cf9a1e3affe14603ae108c774aa584ce63895
|
|
| MD5 |
6cb33116d82f7885a2438beb18ca3171
|
|
| BLAKE2b-256 |
86b69dce15942c53a757c55041374f898668144470208944b78993be0df6bfbd
|
Provenance
The following attestation bundles were made for coreloop-0.1.2.tar.gz:
Publisher:
python-publish.yml on rhiza-fr/coreloop
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
coreloop-0.1.2.tar.gz -
Subject digest:
ca43e5a4189aea04e7eb4cb77b3cf9a1e3affe14603ae108c774aa584ce63895 - Sigstore transparency entry: 1694638196
- Sigstore integration time:
-
Permalink:
rhiza-fr/coreloop@ea3cf1ab77561973187f0e4d0750bfe1162c9756 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/rhiza-fr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@ea3cf1ab77561973187f0e4d0750bfe1162c9756 -
Trigger Event:
release
-
Statement type:
File details
Details for the file coreloop-0.1.2-py3-none-any.whl.
File metadata
- Download URL: coreloop-0.1.2-py3-none-any.whl
- Upload date:
- Size: 46.3 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 |
362a892d84281206280383ab78e6f92616d7125d15fb93ff5cfec759a62fc1cb
|
|
| MD5 |
e02807e6ae862398f059fb15eb79e323
|
|
| BLAKE2b-256 |
d819b5c114720aa139bd755c958ca180a047bd03441a491cd39ad72f0c0a73a4
|
Provenance
The following attestation bundles were made for coreloop-0.1.2-py3-none-any.whl:
Publisher:
python-publish.yml on rhiza-fr/coreloop
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
coreloop-0.1.2-py3-none-any.whl -
Subject digest:
362a892d84281206280383ab78e6f92616d7125d15fb93ff5cfec759a62fc1cb - Sigstore transparency entry: 1694638340
- Sigstore integration time:
-
Permalink:
rhiza-fr/coreloop@ea3cf1ab77561973187f0e4d0750bfe1162c9756 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/rhiza-fr
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@ea3cf1ab77561973187f0e4d0750bfe1162c9756 -
Trigger Event:
release
-
Statement type: