The only LLM harness that's honest about NIH syndrome being the sole reason it exists
README.md

Hwæt #

Hwæt is an LLM harness that I made to see what goes into things like giving a language model memory, proving a concept with MCP, etc.

As a warning this project is very vibecoded and intended mainly for proof of concept of spooky and limited personal use. I do not reccomend you use this on your own without heavy adaptation of it.

Why make something new instead of using Pi or Hermes or Openclaw? #

NIH syndrome. Although partially I wanted a test surface for spooky

Functionality #

The harness server runs on its own device (current target is a raspberry pi or mini pc) and talks MCP to the machines around it, so the agent can act on both its own environment and your devices. The harness server can run background tasks like long horizon programming or schedule cron job events.

Conversations live on the server and are read through a PWA web ui. Tools that are not the server's own come from MCP servers named in mcp.json: a local client such as spooky dials out from the device it runs on and exposes that device's tools, so the agent reaches a laptop, a phone or a box behind NAT without the server ever connecting to it.

The web ui is a single static page: no build step, no framework, and it keeps working offline as an installed PWA.

Memory #

There is a MEMORY.md the agent can remove from or add to, ability to search conversations. (hermes shit)

Goal #

/goal <what this conversation is for> typed into the box sets that conversation's goal. It is stored as an event, shown in the transcript, and put in front of every turn's system prompt, so it survives a fold. /goal alone says what is set and /goal clear drops it. Only the human sets it; the agent cannot.

While a goal is set, a turn does not end when the model stops talking: the server pushes it back with a "the goal is not finished yet" message and keeps offering goal_done, which is the only thing that lets the turn close. Eight pushes is the cap; after that the turn ends with an error rather than spinning.

Routines #

Some work is a graph: draft, review, fix, review again, then ask the human before building. A routine makes that a file rather than a hope. HWAET_HOME/routines/<name>.json turns /name <args> into a command that runs it, and the run is the server's to steer: each node decides which tools the turn can see, and the only way to end a node is the node call, whose fields the server reads to choose what happens next.

A node takes context in and gives context out. An input socket is a file, a directory or a note another node produced; a link carries one node's output into another node's input. Every node also hands on its workdir — the directory the run writes into, under .hwaet/runs/<name>-<stamp>/ inside the file root — so a node can be pointed at what an earlier one wrote. A node's context says what it sees of the conversation: full (everything), fresh (the system prompt and the handoff only, no earlier messages) or since:<node> (from the moment that node ran).

{ "args": "topic",
  "nodes": [
    { "id": "search", "kind": "work", "max_calls": 8, "prompt": "Find sources for {{args}}...",
      "tools": ["web_search", "read_url"], "inputs": [{ "name": "gaps", "type": "text" }],
      "outputs": [{ "name": "sources", "type": "text" }] },
    { "id": "check", "kind": "work", "prompt": "Do the sources answer {{args}}?",
      "tools": ["read_url"], "inputs": [{ "name": "sources", "type": "text" }],
      "outputs": [{ "name": "verdict", "type": "text" }],
      "exit": { "complete": "boolean", "gaps": "string" } }
  ],
  "links": [
    { "from": { "node": "search", "socket": "sources" }, "to": { "node": "check", "socket": "sources" } },
    { "from": { "node": "check", "socket": "verdict" }, "to": { "node": "search", "socket": "gaps" },
      "when": { "field": "complete", "is": false }, "max": 3 }
  ],
  "budget": { "iterations": 12, "cost": 0.5 },
  "output": "evidence.md" }

A link that points backwards is a retry: it carries the exit field of the node it leaves and the value that fires it (is for an exact value, gte for a number), plus max. Past max the run goes forward instead, so a loop cannot spin. budget ends a run that would not end by itself (iterations, and cost against the spend meter's priced total), and output writes the notes to the run's workdir when it finishes — including when the budget runs out, which sends the run to the node that writes that file for one last turn rather than stopping with nothing on disk. A node with no tools may only call node; one with no tools key at all keeps whatever the allow-list gives it. The routine narrows the turn's tools, never widens them.

A node is a stage, not a message: a reply on its own does not end it, so the turn is nudged back into the node until node is called or the nudges run out. A gate is the exception, because it is waiting on a human. max_calls (default 12) caps what a node may spend on its own tools; past the cap the tools are shut and only node is left. Every node is announced when the run enters it — its kind, tools, context and cap — in the run event the panel draws and in the transcript, and a node marked "speak": true is the one whose own text the human sees: the rest keep their working out of the chat.

A node that promises a file cannot end until the file is there: node comes back with a refusal naming the path. Two node kinds do work the model does not drive:

  • fanout runs its personas as reviewers in fresh contexts, in parallel, with the files in reads inlined, and writes their reports to one file. A reviewer ends its report with blocking: <n>, and the numbers are added up into the node's exit fields, so a retry wire can fire on them. Personas are markdown in HWAET_HOME/personas/<name>.md; four of them ship for the EGM routine.
  • gate has no tools: the model asks the human one question and takes their answer on the next turn, and an approve: false sends the run back over a retry wire.

egm.json ships alongside research.json: brief → draft → a four-reviewer fanout → fix → a gate → ship, with a retry from fix back to review while anything is still blocking and a retry from the gate back to the draft if the human says no. It reads the repository, so run it from a home whose file_root is the project.

The routines panel (drawer → routines) is the editor: nodes are boxes you drag around, an output socket dragged onto an input socket makes a link, and the fields of whatever you pick — kind, context, id, tools, exit fields, inputs and outputs, personas, reads, prompt, a wire's field and limit, the budget — are edited beside the canvas. While a run is going the node it is in is marked, with the pass count, notes kept and what the run has spent since it started. Node positions are kept in the file under ui, which the validator ignores.

A save is validated on the server before it lands, so a link to a socket that does not exist, a type that does not match, or a missing prompt is refused with the reason and the file that was working is left alone. The JSON that gets saved is under the panel's json fold. + new routine writes a skeleton file — two nodes, a wire, a budget, and the name you typed — and opens it.

/routines lists what is on disk and says which files do not validate. A name with no valid file is an ordinary message. Typing / in the web client's box offers the commands it knows — /goal, /routines and every routine file on the server, with its argument hint — and tab or enter takes the one highlighted.

Notifications #

The drawer's "notifications" button subscribes this browser to push, with the VAPID key the server hands out in ready. After every turn the server posts one encrypted push (RFC 8291 aes128gcm, RFC 8292 VAPID) to each subscription; the service worker shows it unless a window is already focused on the app. A push service that says a subscription is gone drops it. The key pair lives in $HWAET_HOME/vapid.json, the subscriptions in push.json.

Spend #

The drawer has a spend meter: the tokens a conversation's model calls sent and received, estimated the same rough way the context pill estimates a window, and priced with price_in/price_out (dollars per million tokens, zero by default) when you set a price. Nothing tokenizes, so the number is an estimate, and it counts every call, including the history each one re-sends.

Compaction #

A conversation that fills its window folds instead of dying at it. Once the estimate passes compact_at percent of the window (default 80), the oldest part of the transcript is summarized into one summary event and the turn reads from that summary on; the rows themselves stay in the transcript and the web client, where the fold shows as one collapsible line naming the range it covers. The tail keeps about a third of the window and the summary may take a fifth, so the fold lands well under the threshold that triggered it. A turn after a fold reads the summary first and then the tail the fold kept; the summary stands in for the range it covers, not for the whole transcript up to it. With a model configured the summary is a request of its own; in stub mode it is a mechanical digest, so the fold still happens without one. compact folds on demand (the tool, and the fold history button in the drawer), and compact_at is the threshold.

Output theming### Output theming #

You can also provide a small writeup + stylesheet + assets dir for the agent to use for visual designs like graphs or UIs in order to mitigate the claudisms common to agentic visual design. This theme can also be applied to the web frontend.

Tech stack #

API for interchangeable frontends with reference implementations of agent server + web frontend. Should be ~as simple as possible in design.

Backend #

Elixir app, SQLite (FTS5) for conversations, MCP client for tools that live elsewhere

Frontend #

One static HTML file: no build step, no framework

Running from nix #

nix run .#hwaet-server      # builds the release and starts it
nix run                    # the same thing: it is the default app

The launcher's PATH is what the server's mcp.json resolves command against, so a deployment can hand in the binaries it wants without hwaet depending on any of them:

hwaet-server.override { extraPath = [ spooky ]; }

HWAET_HOME defaults to $HOME/.local/share/hwaet when unset, HWAET_PORT to 4000. The release carries the frontend, the SQLite NIF and the ERTS; closure is around 200 MiB.

The flake builds from the git tree, so untracked files are not in the release. Commit (or at least git add) assets you want shipped.

The NixOS module (nixosModules.hwaet) writes $HWAET_HOME/policy.json before the service starts:

services.hwaet = {
  enable = true;
  policy = [ "clock" "read_file" "run" ];   # the default is the seed list
  allowAllTools = true;                     # or: every tool, overrides policy
  searxUrl = "http://127.0.0.1:8888";       # what web_search asks
};

The module owns that file, so a hand edit is replaced on the next activation. On a box without the module, the same effect comes from the allow_all_tools setting (on/off), which the web client exposes under "advanced".

SearXNG #

web_search needs a searxng instance with json in its search formats. On the same box, bound to localhost:

services.searx = {
  enable = true;
  settings = {
    server = {
      bind_address = "127.0.0.1";
      port = 8888;
      secret_key = "change-me";
      limiter = false;                  # the limiter is what answers 429
    };
    search.formats = [ "html" "json" ];
  };
};

services.hwaet.searxUrl = "http://127.0.0.1:8888";

Bound to 127.0.0.1, so no firewall hole and nothing new exposed. Check it answers json before trusting the tool:

curl 'http://127.0.0.1:8888/search?q=test&format=json'

Without the NixOS module, run the same instance by hand (nix run nixpkgs#searxng with a settings.yml of that shape) and give the server the URL through HWAET_SEARX or the searx_url setting. Which engines answer is the instance's business: its engines and categories settings decide, and the agent can narrow a call to a comma-separated list of them.

Running the scaffold #

nix develop
cd server && mix deps.get
HWAET_HOME=./data HWAET_PORT=4000 mix run --no-halt

The server prints a pairing code on stdout. Pair once and keep the token.

State lives under HWAET_HOME (data/ by default): tokens.json, settings.json, and hwaet.db — conversations, their events, and the full-text index over them. Conversations survive a restart; search reads the same rows.

Web client #

Open http://localhost:4000, paste the code, pair. It is one static file (server/priv/static/index.html): no build step, no framework. Pair gate, conversation list, stream, composer, allow/deny for tool calls, and archive. The drawer's files entry opens the workdir over the transcript: walk it, look at a picture, play audio or video in the browser's own player, read a text file, download the rest. /file answers range requests, so a media element can seek instead of pulling the whole file first. The same jail as the agent's file tools applies, and a directory outside the root is refused. A picture can be attached, pasted or dropped into the composer: the browser downscales it to 1024px JPEG first, so a screenshot costs a couple hundred KB in the stored conversation, and the message carries it as an image part that the transcript shows and the model receives.

Opening a conversation paints its transcript behind a loading pill: the frames are held until the ack, then rendered in small slices with a yield between them, so a long conversation does not freeze the page — the drawer, the composer and the buttons keep working while it fills in, and the transcript appears in one piece when it is done.

Streaming follows the tail while you are at the bottom — thinking lines, replies and tool rows all keep the view current. Scroll up and it stays where you put it, with a ↓ latest button that appears and marks itself when new content lands; one click puts you back at the live tail.

The stop button reflects what the server says: opening a conversation asks whether a turn is still running, so it survives a reconnect instead of vanishing, and cancelling stops every live turn in that conversation rather than only the newest one. Replaying a transcript paints turns that already ended, so a done in that history does not clear the button of the turn running now — only the live socket does.

The ghost beside + opens an incognito chat. It is a conversation the server creates with origin incognito: it never appears in the conversation list, never turns up in search_convs, and is deleted — events, spend rows, blobs — when you close it with the × or when the page drops the socket. A restart sweeps any left behind. The turn it runs is given no memory: MEMORY.md is not in its prompt, memory_read, memory_write and journal are not offered, and the prompt says so. Its journal lines are suppressed too — the turn process carries a flag, so the file and shell writes it causes leave nothing in journal/, and the flag ends with the turn. Other conversations are still searchable; it just writes nothing down.

A running turn shows a stop button. Tool calls and their results are one line each — the call with its clipped arguments, the result with its first line — and opening one shows the arguments or the whole output. Reasoning deltas from a server that sends them (reasoning_content or reasoning) stream into a thinking block that folds itself away as soon as the reply starts; click it to read it back. On a phone the list is a drawer behind a slim top bar rather than a strip across the page, and the transcript never scrolls sideways: wide code and tables scroll inside their own box. A reconnect continues the transcript from the last event instead of replaying it, so the view stays where it was. Agent text renders as markdown from an inline renderer — the text is escaped before any tag is added, so the model cannot inject markup, and nothing is loaded from a CDN, so the offline shell keeps working. Headings, lists, quotes, fences, tables with alignment, backslash escapes and footnotes all render. Inline and display maths render too: $…$, $$…$$, \(…\) and \[…\] are typeset by a KaTeX build that ships inside the release (server/priv/static/katex), so it works with no network. Anything KaTeX cannot parse stays as the literal text the model wrote.

Set serve_webui to off (over the socket, or in server/data/settings.json) to run protocol-only: / and its assets 404, /health, /pair and /socket keep working.

It ships a manifest and a service worker, so it installs as a PWA and opens offline with the cached shell. Service workers need a secure context: https, or localhost. Served as plain http from a LAN address it still works, it just does not register the worker.

Pointing it at a model #

The agent ships in stub mode. To drive a real endpoint, set the model settings over the socket or in settings.json:

agent_mode   model
base_url     http://127.0.0.1:11434/v1     # anything OpenAI-compatible
model        your-model-name
effort       default                       # none, minimal, low, medium, high

effort is sent as reasoning_effort; default leaves the field out so the server's own setting stands. A settings change applies to the running server even when settings.json cannot be written (a file owned by another user, a read-only mount): the value is live for the session and the failure is logged instead. The web client has a select for it in the conversation drawer, and the value is written to settings.json.

Replies stream: the turn emits text deltas as the model produces them, and the web client reconnects with its stored token if the server restarts.

HWAET_API_KEY is used as a bearer token if set. Server tools are gated by policy.json in the data directory; the seed allows the read-only tools, http_request, web_search and run, and leaves write_file/delete_file to the human. server/policy.full.json is the same file with every tool enabled, for a box the agent is trusted with: copy it to $HWAET_HOME/policy.json. scripts/fake_model.py is a scripted endpoint for testing the loop without a model.

Tools #

Server tools run on the server and are gated only by policy.json. The seed allows clock, search_convs, read_conv, memory_read, memory_write, list_dir, read_file, read_image, send_artifact, edit_file, append_file, http_request, web_search, read_url, cron, job, journal and run. write_file and delete_file are implemented but deliberately not seeded: the human opts into the destructive ones by adding them, or by copying server/policy.full.json over policy.json to allow everything at once. policy_read is always available: it reports the list.

journal reads back the action trail: the server appends every file write, memory change and run with its exit code to journal/YYYY-MM-DD.md, and never puts it in the prompt. journal(op="list") shows which days exist and how big they are, op="grep" searches across them, and op="read" returns one day a page at a time — so "what did I change last Tuesday" is answerable without a shell. The journal_append setting turns the trail off; the days already written stay readable.

cron schedules a prompt to come back on its own: cron(op="add", every=3600, prompt="…") for an interval, or cron(op="add", at="09:30", prompt="…") for a daily local time of day, stores a job in cron.json under HWAET_HOME, and each time it comes due the server opens a conversation tagged cron:<id> and starts a turn in it — the same turn any conversation would get, so tools and memory work as usual. op="list" and op="remove" manage the jobs, and the human can edit that file directly. A daily job rolls to the same local time the next day by turning that wall time into an instant with the timezone rules, so a DST shift neither skips nor doubles a run, and a job that fired once is not due again until tomorrow.

run is a shell: the agent gets a real sh -c, because a programming task should not need a bespoke tool per program. The working directory defaults to the file root (the command can still cd anywhere), the environment is stripped to PATH, HOME, LANG and TERM, output is capped at 64 KiB, the timeout defaults to 30s and caps at 10 minutes, and every invocation lands in the journal with its exit code. The exit code is part of the result, so a failing command reads as information rather than as a broken tool.

job is the same shell without the wait: job(op="start", command="…") returns an id at once, the output goes to jobs/<id>.log under the data directory and stops growing at 4 MiB (the command keeps running), and op="list", op="tail" and op="kill" look at it and stop it later, and wait(id, timeout) blocks a turn until the job ends — or until the timeout, which caps at an hour — and answers with its state and output either way, so start → wait → read is one step. A job belongs to the server's process tree, so a restart ends it; the log stays. The web client has a jobs panel next to files: every job with its state, its size and its age, a kill button on the running ones, a × on the finished ones that removes the job and its log (job_remove over the socket; a running job refuses), and a log view with its own refresh. The same list arrives live over the socket, so a job started by the agent shows up without a reload.

There is no step limit: a turn runs until the model stops calling tools, or until a goal it was given is done. The one thing that ends a turn early is a loop — the same tool with the same arguments four times in a row. A fifth identical call is recorded as a skipped call and the turn closes with repeated <tool>(<args>) 4 times in a row; stopping. Four retries leave room for a call that failed for a transient reason. wait is exempt: waiting on the same job again is a second look, not a repeat, so it always runs and the turn goes on.

http_request makes one request — any method, headers and body, a few redirects, capped at 64 KiB of text. HTML comes back as prose unless raw is true, the status is always reported, and a 4xx/5xx still returns its body; it just reads as a failed call in the transcript.

web_search asks a searxng instance. The instance aggregates whichever engines its own settings enable, so adding a source is a change in searxng, not here. Point the server at it with searx_url in settings, HWAET_SEARX in the environment, or services.hwaet.searxUrl (below). The instance needs json in its search formats; without it searxng answers 403 and the tool says so rather than reporting no results.

Hits carry title, url, snippet, the engine that produced them and a published date where that engine has one. Duplicates are dropped, and a host is capped at three hits unless every hit comes from one host or the query names a site:. engine picks which of the instance's engines to ask, comma separated; left out, the instance's own defaults answer. Operators pass through where an engine supports them: quotes, site:, -word, before:/after:.

read_url turns one hit into something readable: title, published date and byline when the page carries them, then the body with scripts, nav, headers and footers dropped. A page longer than 32 KiB comes back in pages: the reply ends with the next offset and asking again with it continues the same page, so a long article's footnotes are reachable. A pdf is read as text with pdftotext (poppler, on the launcher's PATH), which is what makes liner notes and scanned journals quotable. A page that answers 403 is tried once more with another browser string. A page that declares a legacy charset (latin-1, windows-1252) is decoded from it, so an old page reads as text rather than as broken bytes. That is the step after a search — http_request hands back a page's markup as prose, which is not what a citation needs.

An engine that refuses does not take the others with it: when a comma-separated list comes back with nothing and a reason for each, the engines are asked one at a time and whatever answers is kept, with the refusals reported alongside.

Whatever a tool hands back is repaired to valid UTF-8 before it reaches the transcript or the model: a command's output or a file that is not text becomes the replacement character instead of failing the turn.

send_artifact goes the other way: it takes a file under the file root (4 MiB cap) and puts it in the human's chat. A picture, an audio file or a video plays in place with its caption; anything else becomes a download card. The bytes are spilled to /blob/<conv>/<id> and the event keeps the url, the type and the size, so the client loads the media from that route (with ranges, so it can seek) rather than carrying it in the transcript. Nothing is sent back to the model, which only sees the path, type and size.

read_image reads a png, jpeg, webp or gif under the file root (up to 4 MiB) and puts the picture itself in front of the model: the tool result is a short description, and the bytes ride in a following user message as a data: URL, so the next request carries the image. A tool result is text on this protocol, which is why the image travels separately. An MCP tool can return an image part with its result, and the turn puts it in the same place.

File tools are jailed to file_root (default $HWAET_HOME/workspace). Paths are relative (. and / both mean the root), resolved through symlinks before the jail check, reads and writes are capped, writes are atomic, and every mutation lands in journal/.

MCP servers #

Tools that run on another machine come from MCP servers. The server reads mcp.json from HWAET_HOME at boot, next to policy.json and human-edited:

{
  "servers": [
    { "name": "spooky",
      "command": "spooky-relay",
      "args": ["serve", "--stdio", "--listen", "127.0.0.1:7777"],
      "env": { "SPOOKY_STATE": "/var/lib/spooky" },
      "timeout": 120 }
  ]
}

command is resolved on PATH, args and env are passed as written, and the process runs with the data directory as its working directory. Each server is spoken to over stdio with newline-delimited JSON-RPC: initialize, then tools/list once. The tool set is fixed for the life of the process, so a model's cached list is always correct; a device that connects to the far side later is reached through that server's own meta-tools, not by re-listing. A server that dies is restarted after five seconds and its tools stay listed until it answers again; a call made in between fails honestly.

The tools are exposed under the names the server publishes. A name that would collide with a server tool or with another MCP server's tool is prefixed with the server's name. Nothing is approved on this side: policy.json gates the server's own tools only, and an MCP server's far side owns its own approval (that is where the human and the device are). timeout caps one call, in seconds; the default is 120 and the ceiling is 600. An error the server reports (isError) arrives as an ordinary failed tool call, not as a protocol error.

/health reports every configured server, whether it is ready, and the tool names it offers.

The agent's system prompt is a fixed preamble — what hwæt is, the two trust domains, the stores — followed by the system_prompt setting.

Envelope test #

scripts/test_envelope.py drives the whole envelope over a raw socket:

python3 scripts/test_envelope.py 4000 <token>

scripts/test_web_tools.py runs one tool call through the whole path — fake model, server, socket, tool — against a temp data dir:

python3 scripts/test_web_tools.py web_search '{"q":"elixir genserver"}' hexdocs 8911 8910

It passes when the transcript shows the call, a successful result containing the expected text, and a closing turn.

scripts/test_mcp.py proves MCP support end to end: it boots the server with an mcp.json naming scripts/fake_mcp.py, points it at scripts/fake_model.py, and checks that a text result reaches the transcript, a picture result reaches the model as an image part, and an isError result reads as an ordinary failed call:

nix develop -c python3 scripts/test_mcp.py

API.md is the protocol. The stub agent in server/lib/hwaet/agent.ex exists to prove the socket round trip; it is the only piece meant to be replaced by a real model.