A minimal agent loop in bash. Everything else is plugins.
The core script handles plugin discovery, hook dispatch, and the agentic loop. Tools, providers, prompt loading, message serialization, cost tracking, approval gates — all of it lives in plugins that can be written in any language and dropped into well-known directories.
git clone <repo-url> ~/src/harness
ln -s ~/src/harness/bin/harness ~/.local/bin/harness
ln -s ~/src/harness/bin/harness ~/.local/bin/hs # alias
# Dependencies: bash 4+, jq, curl# Set an API key for any discovered provider
export ANTHROPIC_API_KEY="sk-ant-..." # or
export OPENAI_API_KEY="sk-..." # or use a variant
export GROQ_API_KEY="gsk_..."
# Or use your ChatGPT subscription (Plus/Pro/Team/Enterprise)
hs auth set chatgpt # opens browser for OAuth login
# Or use your Claude subscription (Pro/Team/Enterprise)
hs auth set claude # opens browser for OAuth login
# One-shot: run an agent to completion
hs "find all TODO comments in this repo and create a summary"
# Interactive REPL
hs
# Resume a session
hs session list
hs 20260324-143022-12345
# See what's discovered from your current directory
hs helpThe core is a state follower (~100 SLOC). It does three things:
-
Bootstraps with bundled plugins +
~/.harness, then runscall sources— a hookable pipeline that discovers all source directories. The default30-walk-dirshook walks CWD upward collecting.harness/directories;40-scope-providersfilters by active provider. Discovery reruns every loop iteration, so plugins can be added/removed at runtime. -
Dispatches hooks via
call <name>. Hooks are executables sorted by numeric prefix, chained as a pipeline. Hook output is JSON; thenext_statefield drives state transitions. -
Follows states until
next_stateis empty. The core has no built-in transitions — the topologystart → assemble → send → receive → (tool_exec → tool_done → assemble) → doneis emergent from which hooks exist and whatnext_statethey emit.
The loop has zero provider-specific knowledge. Message formats, API calls, response parsing — all of it lives in provider-specific hooks (plugins/anthropic/, plugins/openai/). Provider-agnostic behavior (tool execution, prompt loading, tool discovery) lives in plugins/core/. Additional bundled plugins provide subagent spawning (plugins/subagents/) and skill discovery (plugins/skills/).
Executables in commands/ directories. CLI subcommands are discovered via the same source walk as other plugin types. Each supports one flag:
my-command --describe # one-line human description
my-command [args...] # execute the commandBuilt-in commands: agent, session, tools, hooks, help, version. The default command (bare hs or unrecognized first arg) is agent. Override any built-in by placing a same-named executable in a higher-priority commands/ directory.
Executables in tools/ directories. Each supports three flags:
my-tool --schema # emit JSON tool schema (Anthropic format)
my-tool --describe # one-line human description
my-tool --exec # execute: read JSON input from stdin, write result to stdoutWrite tools in any language. See examples/tools/web_fetch for a Python example.
Built-in tools: bash, read_file, write_file, str_replace, list_dir, agent, skill.
Executables in hooks.d/<stage>/ directories. Stages:
| Stage | stdin | Emits next_state | Purpose |
|---|---|---|---|
sources |
{} |
(n/a — called by core) | Discover source directories |
resolve |
{provider, model} |
(called by agent cmd) | Resolve provider and model |
start |
{} |
assemble |
Session initialization |
assemble |
{} |
send |
Build the API request payload |
send |
payload JSON | receive |
Call the provider |
receive |
API response | tool_exec or done |
Parse response, save message, extract tool calls |
tool_exec |
context w/ tool_calls |
tool_done |
Pop and execute first tool call |
tool_done |
tool result + remaining | tool_exec or assemble |
Save result, loop or continue |
error |
context JSON | (empty = stop) | Handle errors |
done |
context JSON | (empty = stop) | Cleanup (terminal) |
All hooks chain as a pipeline: each receives the previous hook's stdout on stdin. The last hook in each pipeline must emit next_state to declare the transition. Empty or absent next_state stops the loop.
Naming convention: NN-name where NN controls execution order. Examples:
10-messages— runs first in the assemble stage20-tools— runs second50-G-cost-guard— a gate hook (convention, not enforced)
Executables in providers/ directories. Receive the assembled payload JSON on stdin, output the raw API response. Providers also support introspection flags for auto-discovery:
my-provider --describe # one-line description
my-provider --ready # exit 0 if credentials are configured
my-provider --defaults # key=value pairs (e.g. model=claude-sonnet-4-6)
my-provider --env # list supported env vars with descriptionsIf HARNESS_PROVIDER is not set, the hookable resolve stage auto-selects the first discovered provider whose --ready exits 0, and loads its --defaults for unset vars like HARNESS_MODEL.
All built-in providers support --stream for SSE streaming. When stdout is a TTY, responses stream token-by-token to the terminal. Tool calls are dispatched for parallel execution as they arrive during streaming — by the time the response finishes, tools may already be done.
Built-in: anthropic, openai, chatgpt, claude. Each lives in its own provider plugin directory with provider-specific hooks for message assembly and response parsing. The openai provider works with any OpenAI-compatible API (ollama, llama.cpp, vLLM) — set OPENAI_API_URL to point at a local server. The chatgpt provider authenticates via OAuth2 PKCE and uses ChatGPT subscription quotas (Plus/Pro/Team/Enterprise) — no API key needed. The claude provider authenticates via OAuth2 PKCE with Claude.ai subscriptions (Pro/Team/Enterprise) — no API key needed.
Many services share the same API format — they just need a different URL and API key. Instead of writing a full provider plugin, create a .conf file:
protocol=openai
description=Groq (OpenAI-compatible)
model=openai/gpt-oss-120b
url=https://api.groq.com/openai/v1/chat/completions
auth_env=GROQ_API_KEY
Optional fields: max_tokens (override protocol default), stream=always (force streaming even in non-TTY mode — needed for APIs that require stream=true for high token limits).
Place it in any providers/ directory (~/.harness/providers/, .harness/providers/, or a plugin's providers/). Harness resolves the conf to the protocol's provider binary, sets the right env vars, and runs the protocol's hooks — no symlinks or plugin directories needed.
Bundled variants: groq, deepseek, openrouter (OpenAI-compatible), fireworks, zai (Anthropic-compatible).
# Use a variant
export GROQ_API_KEY="gsk_..."
HARNESS_PROVIDER=groq hs "hello"
# Or store credentials persistently
hs auth set groq
HARNESS_PROVIDER=groq hs "hello"
# Use ChatGPT subscription (no API key needed)
hs auth set chatgpt # opens browser for OAuth login
HARNESS_PROVIDER=chatgpt hs "hello"
# Use Claude subscription (no API key needed)
hs auth set claude # opens browser for OAuth login
HARNESS_PROVIDER=claude hs "hello"See docs/PROTOCOLS.md for full protocol details on all plugin types.
~/AGENTS.md # global agent instructions (agents.md standard)
~/.harness/ # global (always loaded)
prompts/ # additional prompt files (sorted, all loaded)
00-persona.md
10-coding-style.md
commands/ # global custom commands
tools/ # global custom tools
hooks.d/ # global hooks
providers/ # global providers and variant confs
sessions/ # session storage (default)
~/project/AGENTS.md # project-specific instructions (agents.md standard)
~/project/.harness/ # project-local (loaded when CWD is under ~/project)
commands/
deploy # project-specific deploy command
tools/
lint-check # project-specific tool
hooks.d/
tool_exec/
05-approve # require approval for this project
skills/
my-skill/
SKILL.md # frontmatter (name, description) + instructions
System prompts follow the agents.md standard: place an AGENTS.md file at the root of any directory with a .harness/ config. For composable prompt fragments, use prompts/*.md inside .harness/.
Skills are directories containing a SKILL.md with YAML frontmatter (name, description). Place them in .harness/skills/ or .agents/skills/ at any level. The 25-skills assemble hook discovers them and injects a catalog into the system prompt; the skill tool loads full instructions on demand.
When multiple .harness/ directories exist in the path from CWD to $HOME, they all contribute. For hooks and tools with the same basename, the most-local one wins. For prompt content, everything is concatenated (global first, local last, so local instructions can refine global ones).
Each session is a directory of markdown files:
sessions/20260324-143022-12345/
session.conf # metadata (model, provider, cwd, timestamps)
messages/
0001-user.md # user message
0002-assistant.md # assistant response (with tool_call blocks)
0003-tool_result.md # tool execution result
0004-tool_result.md # (consecutive results grouped by assembler)
0005-assistant.md # continuation
Each message file has YAML frontmatter with metadata (role, seq, timestamp, model, provider, stop, token counts) and a markdown body. The format is provider-agnostic — tool calls use tool_call (not provider-specific names), stop reasons are normalized (end, tool_calls, length, error):
```tool_call id=call_abc123 name=bash
{"command": "ls -la"}
```
Provider-specific assemble hooks (e.g., plugins/anthropic/hooks.d/assemble/10-messages) translate this canonical format into the provider's API message format. Because the filesystem is the state, you can switch providers mid-session and the message history just works.
| Variable | Default | Purpose |
|---|---|---|
HARNESS_HOME |
~/.harness |
Base config directory |
HARNESS_SESSIONS |
nearest .harness/sessions/ up from CWD, else $HARNESS_HOME/sessions |
Session storage (auto-discovered) |
HARNESS_MODEL |
auto from provider --defaults |
Model identifier |
HARNESS_PROVIDER |
auto: first provider with credentials | Provider plugin name |
HARNESS_MAX_TURNS |
100 |
Max loop iterations |
HARNESS_STREAM |
(unset) | Set to 1 to force streaming in non-TTY mode |
Provider-specific env vars (API keys, endpoints, etc.) are listed by hs help and documented via each provider's --env flag.
Copy an example hook or tool, make it executable, drop it in a .harness/ directory:
# Add a cost tracker to your home config
mkdir -p ~/.harness/hooks.d/receive
cp examples/hooks.d/receive/20-cost ~/.harness/hooks.d/receive/
chmod +x ~/.harness/hooks.d/receive/20-cost
# Add a project-specific tool
mkdir -p .harness/tools
cp examples/tools/web_fetch .harness/tools/
chmod +x .harness/tools/web_fetch
# Add project context (agents.md standard)
cat > AGENTS.md << 'EOF'
This is a Rust project using tokio for async. Run tests with `cargo test`.
EOFThe agent itself can extend the harness by writing new tools or hooks to .harness/ directories during a session. This is intentional.
MIT — see LICENSE for details.