- Elixir 43%
- Python 34%
- HTML 21%
- Nix 2%
- JavaScript <1%
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:
fanoutruns itspersonasas reviewers in fresh contexts, in parallel, with the files inreadsinlined, and writes their reports to one file. A reviewer ends its report withblocking: <n>, and the numbers are added up into the node'sexitfields, so a retry wire can fire on them. Personas are markdown inHWAET_HOME/personas/<name>.md; four of them ship for the EGM routine.gatehas no tools: the model asks the human one question and takes their answer on the next turn, and anapprove: falsesends 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.