Skip to content

About

Simple lightweight agentic harness that responds to triggers including webhooks

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

light-harness

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.

Quickstart

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 listeners

Run the checks:

uv run pytest -q
uv run ruff check .
uv run mypy

Basic Overview

Agent.run(user_input) adds the prompt to a created long-running Conversation, then loops:

  1. Send system + history to the model (along with tool schemas).
  2. If the model requests tool calls, run each tool, append the results, and loop.
  3. 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.

Configuration

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). See config.yaml.example for a template.
  • Env var: LIGHT_HARNESS_HOME overrides the config home directory.
  • .env: the CLI loads ./.env (KEY=VALUE lines) 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 as Config warning: lines on stderr.
  • Env interpolation: config values may use $NAME or ${NAME}; set variables are substituted, unset ones are left as-is -- except in credentials, where that would be unsafe: a provider api_key that references an unset variable is cleared (so agent chat reports "No API key"), and a webhook whose secret references one is disabled. Each case prints a Config warning:.

The file is sectioned:

  • providers: — named endpoints. Each has base_url, api_key, timeout, and a models list (the models you can switch among).
  • agents: — a list of named agents. Each picks a provider and model, plus an optional tools list (opt-in: omit for every available built-in tool, [] for none, or name a subset), an optional workspace, and an optional persona (a key into personas:). The file tools (read_file, write_file, edit_file) are only available to an agent with a workspace: 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_HOME is rejected because that holds config.yaml with your keys. A bad workspace disables the file tools and prints a Config warning. A tools: name that is not a built-in prints a Config warning and 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 prefer tools: [read_file] for agents that don't need to write.
  • personas: — names a persona. Each named persona gets a directory under LIGHT_HARNESS_HOME -- personas/<name>/ -- holding its SOUL.md plus a sessions/ directory of conversation logs. The SOUL.md value 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 editing personas/<name>/SOUL.md -- or through agent web, whose persona field reads and writes that file.
  • interaction: — names which agent each interaction uses; chat.agent is the agent the agent chat loop runs.
  • webhooks: — names HTTP paths that trigger a specific agent. Each entry has a path, an agent (a name under agents:), an optional secret that, when set, requires Authorization: Bearer <secret>, and an optional system_prompt that overrides the agent's default system prompt. Served by agent 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).

Web config UI

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 name

The 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 an X-Agent-Token header. The token is embedded in the served page (which is sent with Cache-Control: no-store and 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 with Cache-Control: no-store as well, so nothing in between can keep a copy.
  • The Host header must name this server: localhost or 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 a 0.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-Origin on /api/* is * by default; set --cors-origin https://ui.example to restrict it, or --cors-origin "" to disable CORS. The value must be * or a bare origin (https://host[:port]: no path, no user: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.

Webhook listeners

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

Extending it

  • Add a tool: implement name / description / parameters (JSON Schema) / async run(arguments: dict[str, Any]) -> str (the arguments arrive as one dict), then registry.register(MyTool()). Before run is called, the registry checks the arguments against parameters (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 whose run blocks the loop synchronously cannot be interrupted: hand blocking work (disk, sockets) to a thread with asyncio.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 -- the tools: config list only selects among the built-ins.
  • Use a different model: implement the LLMClient protocol (async chat(...) -> LLMResponse, async close()) and pass it to Agent. The default client talks to any OpenAI-compatible endpoint; set it per provider with base_url in config.yaml.

About

Simple lightweight agentic harness that responds to triggers including webhooks

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages