Skip to content

Repository files navigation

hai

The Hackable AI: the agent built into hos. An agentic loop in C that talks to any OpenAI-compatible model, operates the machine with a small set of tools, and talks to you by voice.

hai say "what is eating the disk?"     # one question, reply streamed
hai                                    # a REPL
hai talk                               # push to talk (bind to a key)

Run hai inside a project directory and you get a separate agent for that directory: it works there, reads its AGENTS.md or CLAUDE.md, keeps its own conversations, and knows the main assistant is its parent (it can read main's sessions and mail it). hai quit there ends it; hai -s main ... reaches main from anywhere.

haid is the daemon: it holds the conversation, calls the model, runs the tools (shell, files, notifications, speaking, asking you), keeps a transcript, and summarizes when the conversation grows long. hai is the client. They talk over a unix socket with a line protocol, so a key binding, a terminal and a script can all drive the same session.

Model

Any chat-completions endpoint with tool calling: OpenAI, Anthropic's compatibility endpoint, OpenRouter, Ollama, llama.cpp, vLLM.

# ~/.config/hackable/hai.conf
url = http://localhost:11434/v1
model = qwen3:8b

or HAI_URL, HAI_MODEL, HAI_API_KEY in the environment; with no key set, OPENAI_API_KEY is used. keycmd = pass show openai fetches the key from a password store. haid --check prints what will be used.

Those settings are the model called default; give the others a section each and switch between them while hai runs. Or keep every model in a section and name the one to start on:

model = local

[model local]
url = http://hhs:11435/v1
model = qwen3.8:27b-128k

[model deepseek]
url = https://api.deepseek.com/v1
model = deepseek-chat
keycmd = printf %s "$DEEPSEEK_API_KEY"
timeout = 180
budget = 180000

A section states only what differs (its model id defaults to the section name) and may set url, model, key, keycmd, temperature, timeout and budget. hai model lists them with the one in use marked, hai model deepseek switches — the conversation carries over, and everything after that runs on the new endpoint until you switch back or the daemon stops. With a top-level model naming a section there is no separate default entry; a section called default takes that place instead. /model does it in the REPL (tab completes the names), and a name works wherever a model is named: HAI_MODEL=deepseek haid, childmodel in the config, a model given to delegate. A name no section matches is a plain model id, as before.

hai restart (or /restart) starts haid over with hai.conf and the binary read afresh and the conversation resumed — after editing the config or rebuilding; it keeps its pid, so a supervisor sees nothing. Child agents die with it.

As a service

haid is a plain foreground daemon that logs to stderr, so any supervisor runs it: a run script of exec haid -r (-r resumes the newest conversation after every restart) is all one needs. It is not tied to any of them.

Terminal

The REPL is attached to the conversation, not to what it types: it opens on the conversation so far, then shows every run as it happens

  • a message you typed, one that came by mail, one from another terminal or a child agent reporting back - each under its sender's address (the prompt is yours, user@hai> , so the screen reads like a chat), and a second hai on the same agent (or hai -s scout) shows the same thing, joining a run already under way where it is. /open ID shows that conversation the same way. Typing while the agent works queues the line as the next message; when the agent asks something, the prompt turns into ? and the next line is the answer; ^C stops the run. What you type is mail like everything else: written into the agent's own inbox, picked up in order with whatever else arrived, and stored as a turn of the conversation (a new one when there is none).

The REPL edits its line (arrows, emacs keys, history on up/down, tab completion of /commands, session ids, tool and skill names), !cmd runs a shell command locally, and /verbose expands the tool calls (one line each otherwise) and shows their output, redrawing the last few screens that way. With bat installed, shell commands, file edits, permission previews and the code blocks in a reply are syntax highlighted; without it they are colored plainly.

Permissions

Read-only commands (ls, cat, grep, git status, ...) run without asking; anything else, and writing files, is confirmed with you first, a file change shown as a diff: in the terminal, by voice, or with a yes/no menu (hmenu) if neither is around. A deny list (rm -rf /, mkfs, shutdown, ...) never runs. Answer always and that kind of command (or that tool) stops asking for the rest of the session. hai mode auto (or /mode auto in the REPL) turns asking off altogether; hai mode plan puts the agent in plan mode, where it investigates and writes a plan but every change is refused; hai mode ask is the default, and hai mode alone cycles through the three. haid -y (or autonomous = 1) starts in auto. Extend the lists in hai.conf:

[allow]
cmd = docker ps
cmd = systemctl status
[deny]
cmd = git push --force

Voice

Speech to text is hstt (whisper.cpp, local). Text to speech is piper (local): make voice fetches the binary and an English voice into ~/.local/share. hai talk starts recording, hai talk again stops it and hai answers aloud; questions it asks are answered the same way. hai cancel interrupts anything. Notifications follow along (listening, transcribing, what was heard, the reply; never a tool call — a shell command does not pop up); hai notify (or /notify) toggles them, notify = 0 in hai.conf starts without, and asking hai aloud to stop them works too.

Mail

hai has an address. Whatever is mailed to main@hai (by you, a script, a cron job, a child agent) lands in the Maildir ~/.mail/hai/main and starts a run as soon as haid is idle - the same way a line typed into hai does. The reply comes back by mail to user@hai, and anything hai would have had to ask you is left there as a question instead of being done, unless a terminal is attached: then it shows the run and gets the question, and the mailed reply is a copy. Local addresses never leave the machine: hml send delivers @hai into that Maildir tree and everything else over SMTP. Any sendmail-shaped command works (sendcmd in hai.conf); reading the inbox needs no hml at all, it is a directory.

printf 'Subject: disk\n\nWhat is eating the disk? Mail me a summary.\n' | hml send main@hai
hml search to:user@hai tag:unread      # what hai left for you
hml show -- thread:<id>                # the exchange, threaded

The send_mail tool gives hai the same channel: @hai addresses are free, anyone else is real email and is confirmed with you first.

hai can also hand work to child agents: delegate starts a second hai at scout@hai (its own Maildir ~/.mail/hai/scout/: its inbox and its conversations), mails it the task, and gets the report back by mail, as the tool result or, without waiting, as a later inbox turn, so several children can work in parallel. The same name again continues that child's conversation. Children delegate too: scout's own child probe is scout.probe@hai, so an address tells you who spawned whom (childdepth levels at most). A child can be sent to work in a directory, where it also picks up the project's AGENTS.md or CLAUDE.md and any skills in .hai/skills/, and can run on a different model than its parent (childmodel sets the default for all of them). Children die with the parent or on hai new; hai status lists them, hai tree every agent with what it is on and whether it is asking.

There is one tree, and main is its root: every agent on the machine runs with main's leave. A parent forks its own children (so they die with it, and hai new kills them), but only with a ticket from main

  • main issues itself one, a child asks over main's socket and only for a child of its own name - and a newborn haid's first act is to present the ticket to main, which also checks it was forked by the agent that asked; a haid --child started any other way stops on the spot, and a second haid on the same inbox is refused (the inbox is locked). hai agents prints main's registry: every agent alive with its parent. Every agent's prompt names its parent and the root, and hai status/hai who show both.

By default main is a dispatcher (dispatch in hai.conf): quick things it does itself, every job goes to a child named for it (delegate never waits), and main answers at once with who took it, so it is free for your next request while the work runs. A report arriving later is an inbox turn: main tells you the result in a line, mails it to user@hai and shows it as a notification. A question a child asks - the ask tool, or a tool that needs confirming - travels up by mail until someone can answer: a terminal attached to that child, else its parent's, else main's desktop menu (hmenu); the answer goes back down the same way. hai questions lists what waits, hai answer TEXT answers the first, hai -s NAME answer a particular child's directly. hai who says who is on the conversation: the agent and you, the session's id, subject and files, who wrote into it, the terminals attached and where replies go, the group addresses several agents listen to (a message there runs on whichever is free), and the agents above and below. Tell hai to be quiet for a while (a meeting) and it runs hai silent 1h (a duration, or a time like 15:00; hai silent off ends it): notifications, popups and children's questions are held until then, what was held shows as one notification after, terminals attached still see everything, and a voice exchange you start yourself is still spoken.

Conversations are mail too: every agent has one Maildir, ~/.mail/hai/<name>/, its inbox in new/ and every turn of every conversation a message file in cur/, one thread per conversation - readable in any mail client, searchable with hml, and nothing is lost when haid restarts (haid -r resumes the newest, hai sessions and hai open ID switch). hai new (or /new) starts a fresh one and keeps the old on disk; hai forget (/forget) ends the live conversation and deletes it, hai forget ID deletes an older one. Delete a single message in your mail client (or hml tag +deleted) and hai no longer sees it; hml recv removes it. Reply to one of hai's mails from anywhere and the follow-up lands in the same conversation. MAIL.md has the format.

Tools

Built in: shell, files, speaking, asking, notifications, mail sending, skills, delegating to child agents. Bundled as scripts in tools/: git, search (ripgrep), diff, browse (hweb), mail (hml), packages (xbps), windows, show, agents (the other hai agents: who is alive, their status, turns and sessions). A tool is any executable that prints a JSON schema for --schema, reads its arguments from HAI_ARG_*, prints its result, and exits 3 with a question when it wants the user's yes first. Drop your own into ~/.config/hackable/hai/tools/ or a project's .hai/tools/.

MCP servers over HTTP join the same way: one section per server in hai.conf, and its tools appear as NAME_<tool>:

[mcp talent]
url = https://talent.example.com/mcp
headercmd = printf 'Authorization: Bearer %s\n' "$TALENT_KEY"
readonly = whoami search_pool get_person

header = Name: value lines go along verbatim; headercmd runs a command whose output lines are headers, so a token can come from the environment or pass. Tools the server marks read-only run at once, as do the ones named in readonly; the rest are confirmed like any other change (always remembers). The server's own usage notes go into the system prompt. haid --check lists the servers and what they offered.

Soul

~/.config/hackable/hai/soul.md is who your hai is: plain markdown you write - its voice, what it puts first when rules collide, what it calls you, what it refuses even when allowed, what it does when unsure. Twenty lines is plenty. It goes into every system prompt right after the identity, so it colours how the agent speaks and decides without being able to switch the operating rules off. An agent working in a directory (a terminal agent, a delegate sent there) also reads that project's .hai/soul.md, after yours. Edit either and it holds from the next conversation (/new) or restart; each session records the soul it ran with in its stored system message. The one thing the agent may never do is change it: write_file, edit_file and any shell command naming a soul file are refused, allow list or not. haid --check shows which soul files were found; soul in hai.conf moves the user's one.

Skills

skills/*.md are notes hai loads when a task needs them (how hos is laid out, packages, the desktop, mail, the browser). Drop your own into ~/.config/hackable/hai/skills/ - or ask hai to write one: the skills skill tells it the format, where the file goes and what makes a good one, so "remember how we deploy this for next time" becomes a skill file. It is in the index from the next conversation (/new).

Build

make            # needs libcurl headers (libcurl-devel on Void)
make check      # tests, no network
make install    # symlinks into ~/.local/bin

C11, libcurl, vendored cJSON and stb_ds. Configuration is compiled in (config.h) with the conf file and environment overlaid.

About

Hackable AI: agent daemon (haid) and client (hai) for OpenAI-compatible models

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages