01 / The harness
Vela
An agent harness in one static Go binary: the tool-calling loop, the tool belt, the safety rails, and persistent memory. The model is a swappable backend — anything speaking the OpenAI chat API — and Discord is the front end it ships with. It runs anywhere Go cross-compiles; the reference deployment is a LicheeRV Nano with one RISC-V core and 256 MB of RAM, powered from a USB port.
novaoc/vela ↗Install it yourself
Two things are genuinely required: somewhere to talk to it, and a model to think with. Everything else on this site is optional.
- Make a Discord bot. discord.com/developers/applications → New Application → Bot. Copy the token, enable the Message Content intent, and invite it with Send Messages, Read Message History, and Attach Files.
- Get a model key. Any OpenAI-compatible endpoint works — DeepSeek's own API, or an aggregator. Tool calling is required; reasoning-only endpoints will not work.
- Build and run. One binary, one env file, no runtime dependencies:
git clone https://github.com/novaoc/vela && cd vela
make # host build; `make riscv64` cross-compiles for the Nano
cp vela.env.example vela.env
# edit vela.env: DISCORD_TOKEN, DEEPSEEK_API_KEY, VELA_CODERS
./vela
That is a working agent: it answers when mentioned, searches and reads the web, makes charts and images, remembers things across restarts, and keeps per-channel history as plain files on disk. To keep it running unattended, copy the service file from deploy/ — S99vela for SysVinit images, vela.service for systemd.
Set VELA_CODERS to your own Discord user id. The shell and file tools are root-level trust on the box, so an empty allowlist disables that whole capability rather than defaulting it open.
Optional plugins
Vela is useful with nothing attached. Two add-ons extend her from “agent that talks” to “agent that ships”, and each is independent:
- A Rails foundation (
VELA_RAILS_TEMPLATE,VELA_FOUNDATION_ROOT) — a template repository generated apps fork from, so a build starts from real auth, payments, and design instead of an empty directory. Without it, Vela still creates and edits repositories through the GitHub API; she just has no house stack to build from. - A Holodex deploy target (
VELA_SANDBOX_URL,_TOKEN,_SECRET) — somewhere to host verified demos. Without it, a build still ends in a public repository with passing tests; there is simply no live URL at the end.
What this means for /request: the command works either way, and the plan Vela proposes adapts to what is configured. With a Holodex target, “done” means a public repo and a live demo link. Without one, she says so up front and “done” is the repository plus instructions to run it locally — she will not promise a demo she cannot deliver. If you want the demo half, add Holodex; it is one binary and a reverse proxy on any box you already own.
The hardware constraint
Most of Vela's design follows from the board. 256 MB of RAM and a single core mean it cannot bundle a Rails app, run a PostgreSQL suite, or build a Docker image — and the stock image ships without git. So Vela does none of those things. It talks to GitHub over the REST API, and it hands heavy builds to Holodex.
What stays on the board is the part that must stay private: the Discord token, the model key, and the GitHub token. Source bytes and a signature cross the boundary to Holodex; credentials never do.
The agent loop
A Discord message becomes a turn. The turn runs a tool-calling loop against an OpenAI-compatible endpoint: the model answers, or it calls a tool, the result is appended, and the loop runs again until the model produces a final answer or the tool budget is exhausted.
Discord gateway (discordgo)
│
▼
Agent.Handle ──► Agent.run ──► Agent.toolLoop
│
┌──────────────┼──────────────┐
▼ ▼ ▼
web tools code tools media tools
(search, (shell, (images,
fetch) github) charts)
Normal turns answer once. The deliberately slow path is /dive, which raises the tool budget and runs self-review passes — draft, critique, repair — on the theory that a cheap model with more passes beats an expensive model with one shot. Critique passes get a reduced tool belt: pure lookups only, never paid or side-effecting tools, so a review round cannot spend money or post to Discord.
The injection phase guard
The sharpest safety property in the codebase. A turn that has read the open web must not then run code, and a turn that has run code must not then fetch a URL. Otherwise a web page could carry instructions into a shell, or a shell could exfiltrate secrets through an image fetch.
ToolCtx tracks two flags, usedWeb and usedCode. Crossing between lanes does not refuse the request — that would strand the user at a dead end. Instead the tool is left unexecuted and a phase boundary is raised:
type phaseBoundary struct {
Lane string // web | code
Tool string
}
The agent checkpoints the job, starts a fresh phase in the other lane, and continues on its own. Two details matter. A refused call (a non-coder attempting a shell) sets no flag at all, so a refusal cannot poison the rest of the turn. And attach_image counts as web, because it is an arbitrary outbound GET and would otherwise be an exfiltration channel out of a code phase.
The tool belt
Tools are gated by capability rather than offered wholesale. The GitHub tool runs no code on the box, so it is open to the server by default; the shell is root-level trust and is limited to an explicit allowlist, which when empty disables the capability entirely.
| Tool | Gate | Notes |
|---|---|---|
github | allowlist, empty = everyone | API only: create, read, write files, open PRs |
create_rails_app | requires a configured foundation | Generates from the template, always private |
publish_app | operator flag | Makes a finished app public; refuses when publication is off |
verify_repo / deploy_repo | coder allowlist | Streams a commit archive to Holodex |
shell, write_file | coder allowlist | Root-level trust; empty allowlist disables it |
| web, media, data tools | open | Search, fetch, image and video generation, price and benchmark charts |
Deploy secrets are handled out of band. Values are stored on the box, injected into a shell environment by name, and never enter the model's context, history, memory, or logs — with a TTL as a backstop against a forgotten key.
How a build happens
When someone asks for an application, Vela does not start writing files. It posts a plan and waits: a reply of "go ahead" in that thread is treated as approval for the most recent plan, so the conversation is not restarted.
create_rails_app creates a private repository from the foundation template.test target.The archive is signed before it leaves the board. The HMAC covers canonical request metadata followed by the raw compressed bytes, so a valid signature cannot be replayed against a different application name, port, or Dockerfile:
holodex-archive-v1
verify
Card Shop
test
Dockerfile
3000
Generated applications are created private and stay private. Making one public is a separate action that refuses unless the operator has explicitly enabled publication — the model cannot end a containment period on its own.
Configuration
Settings are read from the environment first, then from vela.env or the legacy nanoclaw.env. Keys prefer the VELA_* spelling and fall back to the older NANOCLAW_* names, so a deployed board keeps running across the rename without its credentials being touched:
get := func(k, def string) string {
if rest, ok := strings.CutPrefix(k, "NANOCLAW_"); ok {
if v := lookup("VELA_" + rest); v != "" {
return v
}
}
if v := lookup(k); v != "" {
return v
}
return def
}
Deploying to the board
Cross-compile, copy alongside the running binary, swap atomically, then signal. The binary drains on SIGTERM — it finishes in-flight turns for up to two minutes before exiting — and the supervisor relaunches it about five seconds later. Deployment is confirmed by comparing SHA-256 hashes on both sides, never by assuming the copy worked.
make riscv64
scp vela-riscv64 root@board:/root/vela.new
mv /root/vela.new /root/vela && chmod +x /root/vela
killall vela # drains, supervisor respawns
sha256sum /root/vela
Where the code lives
| File | Responsibility |
|---|---|
agent.go | System prompt, turn handling, tool loop, self-review passes, phase transitions |
tools.go | Tool definitions and the capability gates; ToolCtx and the phase guard |
github.go | API-only GitHub client: repositories, file writes, PRs, template generation |
repoarchive.go | Commit archive download, canonical HMAC signing, verify and deploy calls |
sandbox.go | The lightweight artifact deploy path and its provenance signature |
config.go | Environment and file configuration with legacy-key fallback |
discord.go, discordact.go | Gateway wiring and the Discord actuator interface |
memory.go, person.go | Long-term memory and per-person impressions |