General purpose agent harness inspired by Claude Code
Project description
minimal-agent
A minimal async agent framework in Python. An agent loop drives an LLM that can call tools — you define the prompt, pick the tools, and let the loop handle the rest.
Built on top of the OpenAI SDK (works with any OpenAI-compatible API), Pydantic for schemas, and asyncio for concurrency.
Setup
Requirements: Python >= 3.11, uv for dependency management.
# Install dependencies
uv sync
# Copy the example env file and add your API key
cp .env.example .env
# Edit .env → set LLM_BACKEND_API_KEY=sk-...
The framework supports multiple LLM backends out of the box: openai, openrouter, anthropic, and localhost (for local servers like vLLM, Ollama, LM Studio). Set LLM_BACKEND and LLM_MODEL in your .env file.
Quickstart
The framework ships with a built-in software engineering agent. Out of the box it can read and write files, search codebases, and run shell commands.
import asyncio
from pathlib import Path
from minimal_agent import Agent
from minimal_agent.config import settings
from minimal_agent.llm import LLM, Message, Role
from minimal_agent.tools.builtin.glob import Glob
from minimal_agent.tools.builtin.grep import Grep
from minimal_agent.tools.builtin.read_file import ReadFile
from minimal_agent.tools.builtin.run_shell import RunShell
from minimal_agent.tools.builtin.write_file import WriteFile
async def main():
llm = LLM(
model=settings.LLM_MODEL,
backend=settings.LLM_BACKEND,
)
workspace = Path.cwd()
read_timestamps: dict[str, float] = {}
agent = Agent(
llm=llm,
tools=[
ReadFile(workspace_root=workspace, read_timestamps=read_timestamps),
WriteFile(workspace_root=workspace, read_timestamps=read_timestamps),
RunShell(workspace_root=workspace),
Grep(workspace_root=workspace),
Glob(workspace_root=workspace),
],
workspace_root=workspace,
)
# Start a new conversation. The agent builds the system prompt and
# stamps its identity (model, backend, workspace) onto the session.
session = await agent.create_session()
# Send a message and consume the agent's responses
session.context.add(Message(role=Role.USER, content="What files are in this project?"))
async for msg in agent.run(session.context):
if msg.role == Role.ASSISTANT and msg.content:
print(msg.content)
asyncio.run(main())
The agent decides which tools to call, calls them, reads the results, and keeps going until it has an answer. You don't need to manage the loop yourself.
Streaming responses
By default agent.run() yields one complete Message per step. Pass stream=True to also receive incremental StreamChunks as tokens arrive — useful for live-printing the assistant's reply. Each assistant turn yields its chunks first, then the committed Message (the one added to the conversation); tool-result steps are always plain Messages. Switch on the type to tell a live delta from a committed message:
from minimal_agent.llm import StreamChunk
async for item in agent.run(session.context, stream=True):
if isinstance(item, StreamChunk):
print(item.text, end="", flush=True) # live token
elif item.role == Role.ASSISTANT and item.content:
print() # assistant turn committed — finish the line
Token usage rides the final chunk of each turn, so on_usage works the same in both modes. (on_usage is a display hook for live counters — session accounting happens automatically, no wiring needed.)
Resuming a session
Sessions are persisted to disk automatically. To pick up where you left off:
session = await agent.load_session("20260408-143022-a1b2") # id from a previous create_session()
The agent rebuilds the system prompt fresh against the session's persisted workspace (rebuild, don't restore — nothing stale is replayed) and raises SessionConfigMismatchError if the session was created with a different model, backend, or workspace.
Concepts
Agent
The Agent is the core loop. It takes an LLM, a list of tools, and a prompt that defines its personality. Each call to agent.run(context) drives a decide-act-observe cycle: ask the LLM what to do → execute tool calls → feed results back → repeat until the LLM is done or max_turns is hit.
The agent owns its identity — the same agent instance can drive many sessions, and every session inherits its prompt and behavior.
Session
A Session is a single conversation. It holds the message history and metadata (model, backend, timestamps, token usage, workspace root). Sessions are created with agent.create_session() and resumed with agent.load_session() — the factories stamp the agent's identity onto the session, so the prompt, settings, and context sources are always consistent.
Where sessions live — and how they're recorded — is a SessionManager. A default one records under .minimal_agent/sessions/; construct one only to change policy:
from minimal_agent import SessionManager
agent = Agent(llm=llm, tools=[...], workspace_root=workspace,
sessions=SessionManager(base_dir=Path("/srv/sessions")))
recent = agent.sessions.list_sessions()
Every session records itself as it runs: the transcript (messages.jsonl), a timeline of everything that happened (events.jsonl), and a byte-exact audit of every LLM call (calls.jsonl + blobs/). Token usage — including usage from any sub-agents — is accounted into session.json automatically.
Scope
A Scope is one node in a session's recording tree. The session root is a scope; a tool that runs its own agent opens a child scope under it (ctx.scope.child(...)), which gets the identical artifact kit in agents/<agent-id>/ inside the session directory — full transcript, timeline, and call audit for the nested agent, linked to the exact tool call that spawned it. You only touch scopes when writing a tool that embeds an agent; everything else records itself.
Context
Context is the agent's view of the conversation. It wraps a MessageStore (append-only message log) with the system prompt and a projection strategy. Each LLM call goes through context.assemble(), which prepends the system prompt, projects the history, and injects fresh content from any RUN/CALL-placed context sources — without ever writing that content to the transcript. get_messages() returns the clean conversation (no injected blocks, no I/O), which is what UIs should render.
Tools
Tools are how the agent interacts with the world. Each tool is a class that inherits from BaseTool and defines an input schema (Pydantic model) and an invoke() method. The framework handles argument parsing, validation, permission checks, and error handling — your tool just does its job.
Built-in tools:
| Tool | What it does |
|---|---|
ReadFile |
Read files with optional offset/limit |
WriteFile |
Create or overwrite files |
RunShell |
Execute shell commands with timeout and permission checks |
Grep |
Search file contents using ripgrep |
Glob |
Find files by name pattern |
Spawning sub-agents
The built-in spawn_agents tool lets the orchestrator LLM fan work out to concurrent sub-agents. Each sub-agent is a fully separate Agent with its own isolated Context — it doesn't share history with the orchestrator or with other sub-agents. It runs to completion inside the tool call and returns its final text as the tool result. Sub-agents cannot spawn further sub-agents (the tool excludes itself from any sub-agent's tool set, so there's no recursion).
Wire it up like any other tool, but it needs the orchestrator's LLM and a name→tool map so it knows what it's allowed to hand out:
from minimal_agent import Agent
from minimal_agent.llm import LLM
from minimal_agent.tools.builtin.grep import Grep
from minimal_agent.tools.builtin.glob import Glob
from minimal_agent.tools.builtin.read_file import ReadFile
from minimal_agent.tools.builtin.spawn_agents import SpawnAgents
llm = LLM(model="gpt-4o", backend="openai")
workspace = Path.cwd()
builtin_tools = [
ReadFile(workspace_root=workspace, read_timestamps={}),
Grep(workspace_root=workspace),
Glob(workspace_root=workspace),
]
tools_by_name = {t.name: t for t in builtin_tools}
spawn_agents = SpawnAgents(
llm=llm, # sub-agents reuse the orchestrator's LLM
available_tools=tools_by_name, # pool sub-agents can be given tools from
workspace_root=workspace,
)
agent = Agent(
llm=llm,
tools=[*builtin_tools, spawn_agents],
workspace_root=workspace,
)
The orchestrator LLM decides at call time how many sub-agents to spawn, what each one's task is, and which tools (by name) each gets — up to 10 concurrently. Each SubAgentSpec accepts:
| Field | Meaning |
|---|---|
task |
Self-contained instructions — the sub-agent sees only this task, no orchestrator history |
tools |
Tool names from available_tools to hand out; None gives it everything except spawn_agents itself |
max_turns |
Agent-loop turn cap for this sub-agent (1–20, default 5) |
Results come back concatenated, each labeled [Sub-agent N: <task>], with failures captured inline as ERROR: <type>: <message> rather than raised — a crashing sub-agent doesn't take down the others or the orchestrator.
Every sub-agent is fully recorded under the session's agents/ directory: its own transcript, timeline, and call audit, plus an agent.json naming who spawned it, its task, final status, and token usage. The parent session's timeline gains agent.spawn / agent.end events, and sub-agent usage rolls up into the session's totals — nothing an agent does in a session is off the record.
If you write your own tool that runs an agent inside it, ask the tool's scope for a child and you get the same recording:
async def invoke(self, args, ctx: ToolContext) -> str:
with ctx.scope.child(
spawned_by=self.name, task=args.task, tool_call_id=ctx.tool_call_id
) as scope:
context = scope.new_context(system_prompt=...)
context.add(Message(role=Role.USER, content=args.task))
async for msg in my_agent.run(context):
...
return final_answer
The child scope allocates its directory, records the nested run end to end, and closes with a truthful status (completed / error / abandoned) even if the body raises. Under a bare ToolContext() (unit tests), the same code runs unrecorded.
System Prompt
The system prompt is built from three parts: a behavior prompt (markdown that defines the agent's personality), an environment block (workspace metadata), and context blocks (from SESSION-placed context sources, labeled as a session-start snapshot). The system_prompt module handles assembly — you just pass a markdown file or string. Volatile state like git status doesn't live here: it rides the message list, refreshed each run (see Write a custom context source).
Skills
Skills are reusable prompt templates stored as markdown files on disk. Instead of baking every specialized instruction into the system prompt, you write a SKILL.md per task (e.g. "create a git commit", "review a PR") and the agent loads it on demand.
The model sees a lightweight list of available skills (just names + descriptions) in its system prompt. When one is relevant, it calls the built-in skill tool to load the full instructions. This is the same two-phase pattern Anthropic's own agents use — cheap metadata always, expensive prompt only when needed. See the official Agent Skills Specification for the file format.
Skills are auto-discovered when you pass workspace_root to the Agent. Drop a skill at .minimal_agent/skills/<name>/SKILL.md in your project (or ~/.minimal_agent/skills/ for user-level skills), and it shows up in the agent's skill list. Project-level skills shadow user-level skills with the same name.
Building a Custom Agent
The default agent is a software engineer, but you can build anything. Here's a code review agent with a custom prompt and no shell access:
1. Write a behavior prompt
Create a markdown file — no special syntax, just instructions for the LLM.
<!-- review_agent.md -->
You are a code review assistant. You help developers improve their code
by finding bugs, suggesting simplifications, and enforcing project conventions.
# Tool usage
- Use grep and glob to understand the codebase before commenting.
- Use read_file to see the full context of files mentioned in a review.
- Do not modify any files. You are read-only.
# Style
- Be direct. Say what's wrong, why, and how to fix it.
- Cite specific line numbers when pointing out issues.
2. Build the agent
Point the agent at your prompt file. Since this isn't a general coding agent, we skip the shell and write tools and explicitly choose our context sources.
from pathlib import Path
from minimal_agent import Agent
from minimal_agent.context_sources import GitStatusSource
from minimal_agent.llm import LLM
from minimal_agent.tools.builtin.glob import Glob
from minimal_agent.tools.builtin.grep import Grep
from minimal_agent.tools.builtin.read_file import ReadFile
llm = LLM(model="gpt-4o", backend="openai")
workspace = Path.cwd()
agent = Agent(
llm=llm,
tools=[
ReadFile(workspace_root=workspace, read_timestamps={}),
Grep(workspace_root=workspace),
Glob(workspace_root=workspace),
],
prompt=Path("review_agent.md"),
context_sources=[GitStatusSource()], # git status but no directory tree
workspace_root=workspace,
)
When you pass a custom prompt, context sources default to empty — you opt in to exactly what's relevant. The default agent (no prompt arg) auto-includes GitStatusSource and DirectoryTreeSource. GitStatusSource is RUN-placed, so the model sees the working tree as of the current turn rather than a snapshot frozen at session start.
3. Write a custom tool
Tools are just async classes with a Pydantic schema. Here's a minimal example:
from pydantic import BaseModel, Field
from minimal_agent.tools.base import BaseTool
from minimal_agent.tools.context import ToolContext
class GreetInput(BaseModel):
"""Say hello to someone."""
name: str = Field(..., description="The person's name")
class Greet(BaseTool[GreetInput, str]):
name = "greet"
input_schema = GreetInput
async def invoke(self, args: GreetInput, ctx: ToolContext) -> str:
return f"Hello, {args.name}!"
def render_result_for_assistant(self, out: str) -> str:
return out
The input_schema docstring becomes the tool description the LLM sees. Field descriptions become parameter descriptions. That's all the LLM needs to know how to call your tool.
Optional hooks you can override:
validate(args, ctx)— reject bad inputs before executionneeds_permission(args)— returnTrueif this invocation needs user approvalrender_result_for_assistant(out)— control what the LLM sees as the tool result
4. Write a custom context source
Context sources gather dynamic information about the environment. Any object with a name property and an async gather() method works — no base class needed.
from pathlib import Path
class PackageJsonSource:
"""Injects package.json contents into the system prompt."""
@property
def name(self) -> str:
return "packageJson"
async def gather(self, workspace_root: Path) -> str | None:
pkg = workspace_root / "package.json"
if not pkg.exists():
return None
return pkg.read_text()
Pass it to the agent:
agent = Agent(
llm=llm,
tools=[...],
context_sources=[PackageJsonSource(), GitStatusSource()],
workspace_root=workspace,
)
The gathered content is wrapped as <context name="packageJson">...</context>. Where and when it's gathered is declared on the source itself, via an optional class-level placement (from minimal_agent.context_sources):
placement |
Gathered | Lands |
|---|---|---|
Placement.SESSION (default) |
Once, at session creation | System prompt, labeled as a snapshot |
Placement.RUN |
Once per agent.run() |
Merged into that run's user message |
Placement.CALL |
Before every LLM call | Trailing message, refreshed each call |
Without a placement attribute, a source behaves exactly as before — gathered once, baked into the prompt. Declare Placement.RUN for state that changes between user turns (this is what the built-in GitStatusSource does); reserve Placement.CALL for state that must track the agent's own mid-run side effects — its content can never be prefix-cached, so it's re-sent on every call:
from minimal_agent.context_sources import Placement
class PackageJsonSource:
placement = Placement.RUN # re-gathered at the start of each run
...
RUN/CALL content is injected at LLM-call time and never written to the session transcript. It reaches the conversation only for sessions created through agent.create_session() / agent.load_session(), which wire the agent's live sources into the session's Context.
5. Write a skill
Skills are markdown files with YAML frontmatter. The frontmatter gives the skill a name and a one-line description (this is what the model reads to decide when to use it); the body is the full prompt the model follows once the skill is invoked.
<!-- .minimal_agent/skills/commit/SKILL.md -->
---
name: commit
description: Create a well-structured git commit with a conventional message. Use when the user asks to commit staged changes.
---
# Creating a commit
1. Run `git status` and `git diff --staged` to see what's being committed.
2. Write a commit message in conventional-commits style (`feat:`, `fix:`, `refactor:`, etc.).
3. Keep the subject under 72 characters. Add a body if the change needs context.
4. Run `git commit -m "<message>"` and report the resulting commit hash.
Two frontmatter fields are required:
name— 1–64 chars, lowercase letters, numbers, and hyphens only. Must match the parent directory name.description— 1–1024 chars. This is what the model sees in the skill list, so make it specific enough that the model knows when to invoke the skill.
Optional fields (license, compatibility, metadata, allowed-tools) are described in the official specification.
Skills are discovered from two roots, in priority order:
- Project-local:
.minimal_agent/skills/<name>/SKILL.mdin the workspace root or any ancestor directory. A skill defined at the repo root is found from any subdirectory. - User-level:
~/.minimal_agent/skills/<name>/SKILL.md. Available across every project.
Project-level skills shadow user-level skills with the same name (case-insensitive). Shadowed skills are still tracked so you can see what's being overridden.
Enabling skills
Skills are enabled automatically when you pass workspace_root to the Agent:
agent = Agent(
llm=llm,
tools=[...],
workspace_root=Path.cwd(),
)
The agent scans for skills once at construction, registers the built-in skill tool, and injects the skill list into the system prompt as a <context name="availableSkills"> block. Pass enable_skills=False to opt out.
How the model uses a skill
The model reads the skill list in its system prompt, decides a skill matches the user's request, and calls the skill tool with the skill name. The tool reads the full SKILL.md from disk and returns its contents as the tool result. The model then follows those instructions for the rest of the turn.
This is progressive disclosure: the skill list costs ~100 tokens, but the full prompt is only loaded when it's actually needed. You can have dozens of skills available without paying the token cost of any specific one until the model decides to use it.
Skills can reference additional files (scripts/, references/, assets/) alongside the SKILL.md — the skill prompt just tells the model to read them with its existing tools. See the official specification for the full directory layout and progressive-disclosure pattern.
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 mini_agent_kit-0.1.1.tar.gz.
File metadata
- Download URL: mini_agent_kit-0.1.1.tar.gz
- Upload date:
- Size: 2.0 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6512a2f9d71c21072d271ebcc326e59aae2c580e4b76fd9905d97b5cf056a9e1
|
|
| MD5 |
b4a2a4bd29036aa41f6a5d1b58b4f089
|
|
| BLAKE2b-256 |
c49f196580d5b7ec4f36750a40c3665d5c60c937c1b5b8251fdc4a6a075670df
|
Provenance
The following attestation bundles were made for mini_agent_kit-0.1.1.tar.gz:
Publisher:
release.yml on farhan0167/minimal-agent
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mini_agent_kit-0.1.1.tar.gz -
Subject digest:
6512a2f9d71c21072d271ebcc326e59aae2c580e4b76fd9905d97b5cf056a9e1 - Sigstore transparency entry: 2093724313
- Sigstore integration time:
-
Permalink:
farhan0167/minimal-agent@8f366e0f0f1b0efa5173edcb7c77d443a08e8aac -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/farhan0167
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@8f366e0f0f1b0efa5173edcb7c77d443a08e8aac -
Trigger Event:
release
-
Statement type:
File details
Details for the file mini_agent_kit-0.1.1-py3-none-any.whl.
File metadata
- Download URL: mini_agent_kit-0.1.1-py3-none-any.whl
- Upload date:
- Size: 2.2 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c66c3f048b610a5b332e48328fa5487ec9d00b1a8472fb2bd9da31e94e991064
|
|
| MD5 |
8946e0e9f1ae2bcd512008ad1bc9f2a1
|
|
| BLAKE2b-256 |
a55cef318815abd106722904910183bbf5ae5a891c5148018e39567709e371bd
|
Provenance
The following attestation bundles were made for mini_agent_kit-0.1.1-py3-none-any.whl:
Publisher:
release.yml on farhan0167/minimal-agent
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mini_agent_kit-0.1.1-py3-none-any.whl -
Subject digest:
c66c3f048b610a5b332e48328fa5487ec9d00b1a8472fb2bd9da31e94e991064 - Sigstore transparency entry: 2093724438
- Sigstore integration time:
-
Permalink:
farhan0167/minimal-agent@8f366e0f0f1b0efa5173edcb7c77d443a08e8aac -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/farhan0167
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@8f366e0f0f1b0efa5173edcb7c77d443a08e8aac -
Trigger Event:
release
-
Statement type: