A lightweight agentic harness for personal use, primarily to act as a automated responder to triggers. Obviously more polished harnesses exist that can serve that purpose but they all have tended to be susceptible to feature bloat and instability from vibe-coding.
Additionally, I just wanted to know that I could.
This harness has basic python defined tools that can be called by supporting models.
uv sync --group dev # create .venv, install deps, write uv.lock
cp .env.example .env # secrets referenced from config.yaml as $NAME; `agent` loads ./.env
cp config.yaml.example ~/.light-harness/config.yaml # then edit
uv run agent tools # list the built-in example tools
uv run agent chat "What is 2 + 3?"
uv run agent chat # interactive REPL
uv run agent web # edit the config in a local web UI
uv run agent webhooks # serve the configured webhook listenersRun the checks:
uv run pytest -q
uv run ruff check .
uv run mypyAgent.run(user_input) adds the prompt to a created long-running Conversation, then loops:
- Send
system+ history to the model (along with tool schemas). - If the model requests tool calls, run each tool, append the results, and loop.
- If the model returns a plain message, append it and return it as the answer.
The loop stops when the model stops calling tools or max_steps is reached.
Tool errors are returned to the model as strings rather than raising, so the
agent can recover.
Each chat session is appended to a JSONL log under the active agent's persona
directory (personas/<name>/sessions/), capturing the user prompts, assistant
replies, tool calls, and tool results.
Settings come from the config file, falling back to built-in defaults. There are no per-field environment overrides: each agent resolves its own provider, model, base URL and API key, so agents on different providers never share credentials. Secrets are pulled from the environment by interpolation (below).
- Config file:
$LIGHT_HARNESS_HOME/config.yaml(default home$HOME/.light-harness/config.yaml). Seeconfig.yaml.examplefor a template. - Env var:
LIGHT_HARNESS_HOMEoverrides the config home directory. .env: the CLI loads./.env(KEY=VALUElines) at startup. Variables already set in the environment win over the file.- Errors: a config file that is not valid YAML (or whose top level is not a
mapping) aborts the command with an error. Individual malformed entries
(an agent without a name, a webhook without a
/path, ...) are dropped and reported asConfig warning:lines on stderr. - Env interpolation: config values may use
$NAMEor${NAME}; set variables are substituted, unset ones are left as-is -- except in credentials, where that would be unsafe: a providerapi_keythat references an unset variable is cleared (soagent chatreports "No API key"), and a webhook whosesecretreferences one is disabled. Each case prints aConfig warning:.
The file is sectioned:
providers:— named endpoints. Each hasbase_url,api_key,timeout, and amodelslist (the models you can switch among).agents:— a list of named agents. Each picks aproviderandmodel, plus an optionaltoolslist (opt-in: omit for every available built-in tool,[]for none, or name a subset), an optionalworkspace, and an optionalpersona(a key intopersonas:). The file tools (read_file,write_file,edit_file) are only available to an agent with aworkspace: an absolute directory they are confined to.../traversal, absolute paths outside it, and symlinks pointing out of it are refused, and a workspace that contains (or sits inside)LIGHT_HARNESS_HOMEis rejected because that holdsconfig.yamlwith your keys. A bad workspace disables the file tools and prints aConfig warning. Atools:name that is not a built-in prints aConfig warningand is ignored. Writes are atomic and files over 10 MB are refused. Only regular files are reachable: a FIFO, socket or device inside the workspace is refused, because reading one would block the tool. Anything else inside the workspace is still fair game, so keep it narrow and prefertools: [read_file]for agents that don't need to write.personas:— names a persona. Each named persona gets a directory underLIGHT_HARNESS_HOME--personas/<name>/-- holding itsSOUL.mdplus asessions/directory of conversation logs. TheSOUL.mdvalue seeds that file on first creation only; from then on the file is the source of truth for the system prompt of every agent that references the persona. Edit the prompt by editingpersonas/<name>/SOUL.md-- or throughagent web, whose persona field reads and writes that file.interaction:— names which agent each interaction uses;chat.agentis the agent theagent chatloop runs.webhooks:— names HTTP paths that trigger a specific agent. Each entry has apath, anagent(a name underagents:), an optionalsecretthat, when set, requiresAuthorization: Bearer <secret>, and an optionalsystem_promptthat overrides the agent's default system prompt. Served byagent webhooks.
Config.active_agent() resolves the agent the loop uses (the one named by
interaction.chat.agent, else the first agent, else a default), and
Config.endpoint() resolves that into the effective endpoint.
New features add their own section (see Config in config.py).
agent web serves a small local web page for viewing and editing the config
file (no extra dependencies; binds to 127.0.0.1 by default):
uv run agent web # http://127.0.0.1:8765/
uv run agent web --port 9000 # custom port
uv run agent web --host 0.0.0.0 # expose beyond localhost
uv run agent web --allowed-host box.local # also answer for that nameThe page has a navigation sidebar (currently just Config) and a top bar
showing the config file path with Save and Refresh buttons to the right.
Beneath it, providers (each with base URL, API key, timeout, and an add/remove
able list of models), agents (each with name, provider, model, opt-in tools, and
persona), personas (each with its SOUL.md: the field shows the persona's stored
personas/<name>/SOUL.md -- the prompt the agent actually uses -- and saving it
rewrites that file; any other file a persona has is kept as it is), and the
interaction (which agent the chat loop uses) are edited via form fields. Edits
write back to config.yaml (env refs like $NAME / ${NAME} are preserved), and
on-disk changes are reflected automatically. Only
the sections a request actually names are rewritten: a request that names none
of them is refused, so no save can drop the sections it never touched. A running
agent chat session is unaffected -- changes apply to the next session.
Access control. The config holds API keys and webhook secrets, so the API is
not open to arbitrary web pages even though CORS defaults to *:
- Every
/api/*request needs the per-launch token in anX-Agent-Tokenheader. The token is embedded in the served page (which is sent withCache-Control: no-storeand no CORS headers, so other sites cannot read it) and printed at startup for scripts. It changes on every launch. Every/api/*answer -- which carries the config's API keys and webhook secrets -- is sent withCache-Control: no-storeas well, so nothing in between can keep a copy. - The
Hostheader must name this server:localhostor any*.localhost,127.0.0.1,::1, the bind host, an IP address, or a name passed with--allowed-host(repeat it for each name the server is reached by -- a mDNS name, the name a reverse proxy forwards). This blocks DNS rebinding, and it applies to a0.0.0.0/::bind too: that is the bind a browser can be talked into reaching under a hostname an attacker controls. Access-Control-Allow-Originon/api/*is*by default; set--cors-origin https://ui.exampleto restrict it, or--cors-origin ""to disable CORS. The value must be*or a bare origin (https://host[:port]: no path, nouser:pass@, no whitespace) because it is echoed into a response header verbatim -- anything else is refused at startup instead of becoming an extra header on every/api/*response. Note that with*the token is the only thing protecting the API: anyone who obtains it (e.g. from the startup output) can call it from any origin.
agent webhooks serves the webhooks named in the webhooks: config section on a
local HTTP server (no extra dependencies; binds to 127.0.0.1:8787 by default).
Each POST to a configured path runs that webhook's agent with the parsed
request body (JSON is re-serialized; non-JSON is passed through as text) and
returns the agent's answer. A webhook with a secret requires the request to
carry Authorization: Bearer <secret>; others are unauthenticated.
Every request must also carry a Host header that names the server -- a loopback
name, an IP address, the bind host, or a name passed with --allowed-host (the
name a reverse proxy forwards). A request under any other hostname gets 403 and
no agent is run for it.
A webhook may carry an optional system_prompt, which overrides the agent's
default (persona-based) system prompt for that webhook's runs.
At startup the server prints warnings on stderr naming the webhooks that cannot work as
configured: the unauthenticated ones, and any webhook whose agent cannot get the tools its
config implies -- an omitted tools list means every built-in, so a webhook whose agent has
no workspace loses read_file, write_file and edit_file, and a webhook naming an agent
that does not exist cannot run at all.
uv run agent webhooks # http://127.0.0.1:8787/
uv run agent webhooks --port 9001 # custom port
uv run agent webhooks --host 0.0.0.0 # expose beyond localhost
uv run agent webhooks --allowed-host hooks.example # behind a proxy at that name- Add a tool: implement
name/description/parameters(JSON Schema) /async run(arguments: dict[str, Any]) -> str(the arguments arrive as one dict), thenregistry.register(MyTool()). Beforerunis called, the registry checks the arguments againstparameters(required keys and scalar types) and returns a descriptive error to the model on a mismatch; each call is also limited to 60 seconds -- but a timeout can only fire while the event loop is free, so a tool whoserunblocks the loop synchronously cannot be interrupted: hand blocking work (disk, sockets) to a thread withasyncio.to_thread, as the built-in file tools do. A timed-out call is abandoned, not killed: its worker thread runs to completion in the background. Tools registered this way are used programmatically -- thetools:config list only selects among the built-ins. - Use a different model: implement the
LLMClientprotocol (async chat(...) -> LLMResponse,async close()) and pass it toAgent. The default client talks to any OpenAI-compatible endpoint; set it per provider withbase_urlinconfig.yaml.