A minimal coding agent named after Agent K from Men in Black.
Features • Quick Start • Tutorial • How k Was Built • Build Your Own • Commands • Reference
- Minimal. One file, stdlib, no frameworks.
- OpenAI Responses and Chat Completions APIs. Native function calling with self-contained request history; select the API your provider supports.
- Tool loop.
read,write,edit,bash— the four operations that cover most coding tasks. - Context-mode compaction. Large tool outputs are truncated to a useful head/tail preview; the full text is saved to disk alongside the session.
- Session persistence. Every conversation is saved to
~/.k/sessions/<id>.json. Resume any session by ID. - Session discovery.
/resumelists up to 20 recent sessions;/resume <id>switches to one. - Session metadata. Saved sessions include a title from the first prompt, model, cwd, and update time.
- Session models.
/model <model-name>sets and persists the active session's model. - Activity status. The pinned status temporarily shows
thinkingand tool activity labels. - Project-bound file tools.
read,write, andeditonly touch files inside the current working directory, including symlink escapes. - Explicit shell consent. Every
bashcommand requires interactive per-command approval; noninteractive runs reject all shell commands. - One-turn Git undo.
/undoreverts the last completed agent turn from a clean worktree. - Skills. Load Markdown skill files from
~/.agents/skills/to inject system instructions dynamically. - Terminal UI. Pi-inspired truecolor light/dark themes, pinned status line (showing model, tokens, git branch, cwd), colored Markdown rendering, multiline prompts.
- Portable. Works with OpenAI-compatible providers that support either Responses or Chat Completions — OpenAI, OpenRouter, xAI, local LLMs, and OpenCode Zen.
# Prerequisites: Python ≥3.12, uv (or pip)
uv sync
cp .env.example .env # add OPENAI_API_KEY
uv run python main.py# Try it instantly (no clone needed):
uvx --from mini-agent-k k
# Or install permanently via pip/uv:
pip install mini-agent-k
# then:
kPyPI distribution:
mini-agent-k. The executable is namedk.
Resume a session:
uv run python main.py --resume 20260727-120000-ab12cdWhen you run k, you get a prompt (❯). Type a coding task and the agent will reason over it, call tools (read, write, edit, bash), and return an answer. Press Ctrl+J to add a line break; press Enter to submit the complete prompt.
Example session:
❯ read the main.py file and summarize the architecture
k | /home/user/project | main | model: openrouter/free | tokens: 1234 | context: 567
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The project is a single-file coding agent with:
• OpenAI Responses API loop
• 4 tools: read, write, edit, bash
• TTY-aware terminal UI with light/dark themes
• Session persistence under ~/.k/sessions/
• Skills system for injecting system instructions
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
You can chain multiple tasks in the same session — the conversation history is preserved.
Passing images: k's read tool detects image files (jpg, png, gif, webp) and sends them as base64 attachments to the model. The model can inspect screenshots, diagrams, or UI mockups.
Skills are Markdown files in ~/.agents/skills/<name>/SKILL.md. They act as additional system instructions injected per session.
~/.agents/skills/
python/
SKILL.md # "Follow PEP 8, use type hints..."
review/
SKILL.md # "Focus on security and edge cases..."
In-session commands:
| Command | Action |
|---|---|
/resume |
List up to 20 recent sessions |
/resume <id> |
Switch to a saved session |
/model <model-name> |
Set and persist this session's model |
/undo |
Revert the last completed agent turn in a clean Git worktree |
/skills |
List available skills |
/skill <name> |
Load a skill for this session |
/skill clear |
Remove all loaded skills |
Sessions are saved automatically after every completed answer. Use /resume to list up to 20 recent sessions; each entry shows its title, model, cwd, and update time. The title comes from the first prompt. Use /resume <id> to switch sessions, or resume at launch with:
uv run python main.py --resume <session-id>Use /model <model-name> to change and persist the active session's model. While the agent works, the pinned status temporarily shows thinking and the active tool name.
Safety: The read, write, and edit tools resolve every path against the current working directory and reject anything outside it, including symlink escapes. bash is not sandboxed, so every shell command requires interactive per-command approval (Allow bash command? [y/N]), and noninteractive runs reject all shell commands. K is not a container or OS sandbox — run it only in environments you trust.
Undo: /undo works once, only after a task that began with a clean Git worktree. It preserves pre-existing files and declines if anything changed since the task.
uv run python main.py --theme dark # dark mode
OPENAI_MODEL_NAME=gpt-4o uv run python main.py # custom modelFor OpenCode Zen, configure its OpenAI-compatible endpoint and use Chat Completions mode:
OPENAI_BASE_URL=https://opencode.ai/zen/v1 \
OPENAI_API_KEY="$OPENCODE_API_KEY" \
OPENAI_MODEL_NAME=<zen-model-id> \
K_API_MODE=chat-completions \
uv run python main.pySet defaults via environment variables:
K_THEME—light(default) ordarkOPENAI_MODEL_NAME— model name (default:openrouter/free)K_API_MODE—responses(default) orchat-completions; use the latter for OpenAI-compatible providers without a Responses endpoint, including OpenCode Zen.
When a tool call returns more than 12,000 characters, k truncates it to a 6,000-char head and 2,000-char tail with a notice:
[context-mode: output compacted from 45000 chars]
<first 6000 chars>
...
<last 2000 chars>
Full output saved: ~/.k/sessions/<id>-artifacts/bash-142530-abc123.txt
The full output is always saved to an artifact file next to the session, so nothing is lost even though the model only sees the preview.
| Boundary | Limit |
|---|---|
| Text file read | 1 MB (MAX_TEXT_FILE_BYTES) |
| Image read | 5 MB (MAX_IMAGE_FILE_BYTES) |
| Shell command duration | 60 s max (DEFAULT_BASH_TIMEOUT) |
| Tool turns per prompt | 20 (MAX_AGENT_TOOL_TURNS) |
| Retained history records | 200 (MAX_HISTORY_ITEMS) |
Tool output larger than 12,000 characters is still captured and written to a local artifact before it is compacted; the byte limits above only bound what the model reads.
k started as a 50-line experiment and is now about 1,000 lines of deliberately small Python. This is the story of the milestones that shaped the current agent.
198628e init commit
The first commit was a bare OpenAI Responses API loop: send a prompt, parse tool calls, dispatch them, feed results back. Two tools (read, bash), no state, no TUI. Just the loop — the hardest part.
# The essence hasn't changed
response = client.responses.create(model=MODEL, instructions=SYSTEM_PROMPT, input=history, tools=TOOLS)
while True:
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
print(response.output_text)
break
# dispatch, feed back, loop28e512d feat: add native local tools
Added write and edit — the 4-tool set that covers almost every coding task. read gained numbered line output so the model could reference line numbers in edits. edit uses exact string replacement (rejecting ambiguous matches) — the simplest correct implementation.
78d2f71 feat: add native tool calling tui
First TUI: colored output for tool calls (blue), errors (red), answers. The model's tool invocations were printed so the user could see what the agent was doing.
c3d7e2a chore: rename agent to k
977ee63 feat: show model and token usage
51ac10e feat: show cli context in status
1da8232 fix: keep cli running after each task
47296e1 feat: pin cli statusline
b4680b3 docs: describe pinned cli statusline
9dd8752 feat: clear terminal at startup before rendering pinned status
bf6395e fix: preserve prompt position when repainting status
dad5c42 render 'k' in status lines with reversed foreground (bg=accent)
The status line evolved over several commits. The key trick: ANSI escape sequences set a scroll region (\033[2;Nr) so row 1 stays pinned while rows 2+ scroll. Each task repaints the status without moving the input cursor (\033[s / \033[u).
k | /repo | main | model: openrouter/free | tokens: 1234 | context: 567
2eef8bc feat: handle cli interruption cleanly
4aca4e9 feat: save and resume sessions
1d79899 feat: add slash command handling
Ctrl+C / Ctrl+D / blank input all exit cleanly. Sessions serialize the full history array to ~/.k/sessions/<id>.json — resume with --resume. Slash commands (/quit, later /skills, /skill) were wired in.
4aca4e9 feat: save and resume sessions
bbc8ed7 feat: show k sunglasses banner
73c5415 feat: compact large tool outputs
Sessions save automatically after every completed answer. The session ID is a timestamp + random suffix: 20260727-120000-ab12cd. Large tool outputs (>12KB) are truncated with a head/tail preview and the full output is saved to an artifact file next to the session JSON.
36a5eb5 docs: document minimal agent usage
1b65489 docs: add VHS demo recording
b161b48 feat: render markdown with accessible terminal colors
4b92598 fix: preserve terminal theme contrast
54fd651 feat: add pi-inspired terminal palettes
Markdown rendering came next: headings, bold, italic, code, lists, blockquotes, fences. Two color palettes (light/dark), Pi-inspired. The demo GIF was recorded with VHS (assets/demo.tape).
966e20e docs: add skills support design
f8f386d feat: add skill discovery
e0ec168 feat: add session skill commands
53a24a7 docs: document skill commands
7e48984 ignore files
9ce9ce7 refactor: remove built-in skills, only ~/.agents/skills/
8afabc0 fix: discover skills from ~/.agent/skills/<name>/SKILL.md
750fb36 fix: correct skills dir to .agents
Skills are Markdown files in ~/.agents/skills/<name>/SKILL.md. They get appended to the system prompt as additional instructions. No plugin system, no DSL — just text injected into the prompt. The directory path was iterated a few times (~/.agent/ → ~/.agents/).
be3f922 feat: add chat completions provider mode
ad5aa4c feat: add multiline input support with Ctrl+J line break binding
83fadf7 feat: add console entry point for uvx mini-agent-k
f3df926 fix: derive version from git tag via setuptools-scm
The original Responses loop became portable: k can now use OpenAI-compatible Chat Completions providers, installs as the k command, and derives releases from Git tags. Readline input history and Ctrl+J multiline prompts made the interactive loop practical for real tasks.
6fc6356 feat: add session metadata and listing helpers
1036290 feat: support session models and activity status
c4078cd feat: add one-turn clean-worktree undo
33b7a2e feat: add resume model undo and activity commands
Sessions gained titles, timestamps, model and working-directory metadata, /resume discovery, and per-session /model. The status line reports active work. /undo adds a deliberately narrow escape hatch: it restores one completed turn only when the worktree started clean and has not changed since.
c362899 fix: harden session safety workflow
98da0c7 fix: confirm indirect dangerous shell commands
b6666a4 fix: confirm wrapped dangerous shell commands
c50cbea fix: recover invalid chat history
c3f0f03 fix: skip empty reverse patch in /undo
The latest work made the small surface safer rather than broader: file tools stay inside the project (including against symlink escapes); every shell command needs interactive approval; corrupt sessions and malformed provider history are recovered safely; history, file reads, image reads, shell time, and tool turns all have explicit bounds.
Every feature followed the same path:
- Add the minimum that works (one file, no deps)
- Ship it — the simplest version first
- Iterate on edges — error handling, safety, and UX polish
- Document — explain the design, not just the API
No plugin system, database, async runtime, or framework: only one Python file and the dependencies needed to call the model API.
k is designed as both a tool and a reference implementation. The entire agent is one Python file (about 1,000 lines) — here's how to build a minimal coding agent from scratch.
import json
from openai import OpenAI
client = OpenAI()
tools = [...] # tool definitions
history = [{"role": "user", "content": task}]
response = client.responses.create(
model="openrouter/free",
instructions="You are a coding assistant.",
input=history,
tools=tools,
)
while True:
calls = [item for item in response.output
if item.type == "function_call"]
if not calls:
print(response.output_text)
break
for call in calls:
result = dispatch_tool(call.name, call.arguments)
history.append(call)
history.append(result)
response = client.responses.create(
model="openrouter/free",
instructions="You are a coding assistant.",
input=history,
tools=tools,
)That's it. The loop is: send input → execute tool calls → feed results back → repeat until the model answers in plain text.
A coding agent needs exactly 4 tools to do almost everything:
| Tool | What it does |
|---|---|
read |
Read file contents (text as numbered lines, images as base64) |
write |
Create or overwrite files |
edit |
Replace exactly one occurrence of text in a file |
bash |
Run shell commands (git, grep, tests, ls) |
Keep tool definitions small and strict. Set strict: true and additionalProperties: false in the JSON schema so the model doesn't hallucinate parameters.
{
"type": "function",
"name": "read",
"description": "Read file contents.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path to read"},
"offset": {"type": "integer", "description": "Start line (1-indexed)", "default": 1},
"limit": {"type": "integer", "description": "Max lines to read", "default": 2000},
},
"required": ["path"],
"additionalProperties": False,
},
"strict": True,
}Save history so the user can resume:
import json
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
SESSIONS_DIR = Path.home() / ".k" / "sessions"
def save_session(session: dict) -> None:
path = SESSIONS_DIR / f"{session['id']}.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(session, indent=2))
def load_session(session_id: str) -> dict:
return json.loads((SESSIONS_DIR / f"{session_id}.json").read_text())Model context windows are finite. When tool output exceeds a threshold, compact it:
MAX_OUTPUT = 12_000
def compact_output(text: str, label: str, session_id: str) -> str:
if len(text) <= MAX_OUTPUT:
return text
# Save full output to disk
artifact_dir = SESSIONS_DIR / f"{session_id}-artifacts"
artifact_dir.mkdir(parents=True, exist_ok=True)
path = artifact_dir / f"{label}-{uuid4().hex[:6]}.txt"
path.write_text(text, encoding="utf-8")
# Return useful preview
return f"[compacted from {len(text)} chars]\n{text[:6000]}\n...\n{text[-2000:]}"A pinned status line (row 1) that stays visible while the scrollable area (rows 2+) contains conversation:
import shutil
def pin_status(text: str) -> None:
if not sys.stdout.isatty():
return
rows = shutil.get_terminal_size().lines
# Reserve row 1 for status, rows 2+ for scrollable content
sys.stdout.write(f"\033[2;{rows}r\033[1;1H\033[2K{text}\n")
sys.stdout.write("\033[2;1H")
sys.stdout.flush()ANSI escape sequence breakdown:
\033[2;Nr— set scroll region (row 2 to N)\033[1;1H— move cursor to row 1\033[2K— clear entire row
Allow users to load custom system instructions from Markdown files:
def load_skills(names: list[str], base_prompt: str) -> str:
skills_dir = Path.home() / ".agents" / "skills"
blocks = []
for name in names:
path = skills_dir / name / "SKILL.md"
if path.exists():
blocks.append(f"### {name}\n{path.read_text()}")
if blocks:
return base_prompt + "\n\n## Active skills\n\n" + "\n\n".join(blocks)
return base_prompt- Self-contained history vs
previous_response_id:ksends the full history on every turn. It supports both Responses and Chat Completions formats, so providers only need to implement one of those APIs. - No streaming: Blocking
responses.create()calls keep the agent loop simple. Streaming adds complexity with no functional benefit for a tool-calling agent. - One file, no framework: The agent remains one file (about 1,000 lines). There is no plugin system, database, or async framework. Add structure only when the file becomes hard to change.
"""minimal_agent.py — your first coding agent in <100 lines."""
import json, subprocess, sys
from openai import OpenAI
client = OpenAI()
def bash(command):
return subprocess.run(command, shell=True, capture_output=True, text=True).stdout
def read(path):
with open(path) as f:
return f.read()
TOOLS = [{
"type": "function", "name": "bash",
"description": "Run a shell command",
"parameters": {"type": "object", "properties": {"command": {"type": "string"}},
"required": ["command"], "additionalProperties": False},
"strict": True,
}, {
"type": "function", "name": "read",
"description": "Read a file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}},
"required": ["path"], "additionalProperties": False},
"strict": True,
}]
HANDLERS = {"bash": bash, "read": read}
history = [{"role": "user", "content": sys.argv[1]}]
response = client.responses.create(
model="openrouter/free",
instructions="You are a coding assistant. Use tools to answer.",
input=history, tools=TOOLS,
)
while True:
calls = [i for i in response.output if i.type == "function_call"]
if not calls:
print(response.output_text)
break
for call in calls:
result = HANDLERS[call.name](**json.loads(call.arguments))
history.append(call)
history.append({"type": "function_call_output", "call_id": call.call_id, "output": str(result)})
response = client.responses.create(
model="openrouter/free",
instructions="You are a coding assistant.",
input=history, tools=TOOLS,
)Usage: python minimal_agent.py "find all python files larger than 10KB"
| Command | Action |
|---|---|
/quit |
Exit without calling the model |
/resume |
List up to 20 recent sessions |
/resume <id> |
Switch to a saved session |
/model <model-name> |
Set and persist this session's model |
/undo |
Revert the last completed agent turn in a clean Git worktree |
/skills |
List available skills in ~/.agents/skills/ |
/skill <name> |
Load a skill for this session |
/skill clear |
Clear all loaded skills |
| Ctrl+J | Insert a line break in the current prompt |
| blank prompt / EOF (Ctrl+D) | Exit cleanly |
| Ctrl+C | Interrupt / exit |
k/
├── main.py # the entire agent
├── test_main.py # unittest test suite
├── pyproject.toml # uv/pip project config
├── assets/
│ ├── agentk.jpg # banner image
│ └── k-demo.gif # demo animation
└── .env # OPENAI_API_KEY (not committed)
Run the full verification suite (this is what CI enforces):
uv sync --extra dev --locked
uv run ruff check main.py test_main.py
uv run python -m unittest -v
uv build| Variable | Default | Description |
|---|---|---|
OPENAI_API_KEY |
— | API key for the LLM provider |
OPENAI_MODEL_NAME |
openrouter/free |
Model identifier |
K_API_MODE |
responses |
responses or chat-completions |
K_THEME |
light |
Color theme (light or dark) |
~/.k/sessions/<id>.json # conversation history + skills
~/.k/sessions/<id>-artifacts/ # compacted tool outputs
openai>=2.48.0— OpenAI Responses and Chat Completions APIspython-dotenv>=1.2.2—.envloading
No web framework, no database, no async runtime, no plugins.
