A small, opinionated agent runtime on Elixir/OTP.
Drives any OpenAI-compatible chat-completions endpoint — OpenAI, Kimi / Moonshot, DeepSeek, Mistral La Plateforme, vLLM, Ollama in OpenAI-compat mode, or anything else that speaks the same wire format. Subagents are isolated OTP processes, scheduling is persistent (Oban), and reliability comes from supervision trees rather than hand-rolled error handling.
- Agent loop — one
GenServerper conversation, bounded tool-calling loop, durable in Postgres. Crash one and the others keep running. - Personas — multiple agents side-by-side, each with its own SOUL, user model, memories, skills, schedules, connectors. No cross-contamination.
- Tools — ~30 built-ins covering memory, web fetch / search, shell, sandboxed Elixir eval, image generation, browser automation, file I/O, persona-to-persona messaging, subagent spawn, and more. Plus MCP servers via Hermes.
- Memory — Postgres-backed long-term memory with tsvector full-text search; optional pgvector + OpenAI embeddings for hybrid semantic search. Append-only stream + an upsert-in-place user model.
- Skills — agentskills.io-format
SKILL.mdfiles, hot-reloaded from disk. Names and descriptions inject into the system prompt; bodies load on demand. - Schedules — Oban-backed heartbeats and cron prompts per
persona. Agents can also manage their own schedules via the
crontool. - Connectors — Telegram (ExGram) and Discord (Nostrum), routed per-persona by chat/channel id.
- Web UI — Phoenix LiveView at
http://localhost:4000: conversations list, chat page with streaming + tool calls, Oban Web, LiveDashboard. - Observability —
:telemetry.span/3wrappers on LLM and tool calls with OpenInference-shaped attributes; ship to any OTLP collector.
Prereqs: Elixir 1.19 / OTP 27+ and a running Postgres (with the
vector extension if you want semantic search — brew install pgvector on macOS).
cp .env.example .env
# fill in at minimum LLM_API_KEY, LLM_API_BASE, LLM_MODEL
mix deps.get
mix ecto.setup
iex -S mixconfig/runtime.exs auto-loads .env from the project root — no
source .env step. Already-exported shell vars take precedence.
Two example env blocks:
# OpenAI
export LLM_API_KEY=sk-...
export LLM_API_BASE=https://api.openai.com/v1
export LLM_MODEL=gpt-4o-mini
export LLM_THINKING_STYLE=openai # or "none" if not on a reasoning model
export LLM_PROVIDER_LABEL=openai
# DeepSeek
export LLM_API_KEY=...
export LLM_API_BASE=https://api.deepseek.com/v1
export LLM_MODEL=deepseek-chat
export LLM_THINKING_STYLE=anthropic
export LLM_PROVIDER_LABEL=deepseekOnce iex -S mix is up, browse http://localhost:4000:
/— Conversations list. Create / open, with an optional title./c/:id— Chat page. Streaming messages, collapsible tool calls, reasoning blocks./dashboard— Phoenix LiveDashboard./oban— Oban Web (queues, retries, inspect).
From iex:
Neoharness.say("remember that my favorite editor is nvim")
Neoharness.say("trader:main", "what's BTC doing today?") # persona-prefixedConnectors with no token silently skip boot — Telegram if
TELEGRAM_BOT_TOKEN (or TELEGRAM_BOT) is unset, Discord if
DISCORD_BOT_TOKEN is unset.
Each persona lives in its own directory under ~/.neoharness/agents/:
~/.neoharness/
├── agents/
│ └── main/ # auto-seeded on first boot
│ ├── SOUL.md # voice, principles, style
│ ├── schedules.jsonc # heartbeats + cron prompts
│ ├── maintenance.jsonc # heartbeat focus modules
│ ├── connectors.jsonc # which chats / channels this persona owns
│ ├── tools.jsonc # tool deny-list + thinking knob
│ └── skills/ # persona-scoped skills (optional)
├── skills/ # shared across every persona (optional)
└── mcp.json # shared MCP servers (optional)
Add more personas by creating sibling directories under agents/.
Each gets its own user model, memory namespace, schedules, and
connector routing. See docs/personas.md.
Drop a ~/.neoharness/mcp.json (same schema Claude uses):
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}Restart the app. Tools from each server appear as <server>__<tool>
in the agent's tool list. See docs/mcp.md.
Drop a file at ~/.neoharness/agents/<persona>/schedules.jsonc:
Restart the app — Oban Cron picks them up. Or let the agent manage
its own schedules via the cron tool. See docs/schedules.md.
Point a collector at the app to stream LLM and tool spans:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
iex -S mixWithout the env var, traces stay in-memory — no network calls.
lib/neoharness/
├── application.ex
├── repo.ex
├── telemetry.ex # :telemetry → OpenTelemetry bridge
├── llm/ # Chat client, message + tool schema types
├── agent/ # GenServer + pure loop + context + summarizer
├── tools/ # registry, runner, builtin/, behaviour
├── memory/ # Ecto schemas + store + summary + user model
├── personas/ # registry + on-disk loader
├── subagent/ # supervisor + server + spawn tool
├── skills/ # loader + registry (file-watched)
├── mcp/ # pool + client_def + config + tool_adapter
├── scheduler/ # heartbeat + cron + maintenance modules
└── connectors/ # telegram, discord, router
lib/neoharness_web/ # Phoenix endpoint, router, LiveView, telemetry
mix testThe suite is kept fast (under 10s) and runs against a real Postgres.
Real LLM calls are mocked at the Chat boundary so tests stay
deterministic.
- Setup — install, env vars, first boot.
- Chatting — web UI, iex, Telegram, Discord.
- Personas — SOUL, user model, schedules, skills.
- Tools — every built-in tool the agent can call.
- Skills — authoring reusable playbooks.
- Memory — structured memory + semantic search.
- MCP — external tool providers.
- Schedules — heartbeats and cron.
- Architecture — supervision tree, where each concern lives.
MIT.
[ { "name": "morning_check", "type": "heartbeat", "cron": "0 9 * * *" }, { "name": "weekly_review", "type": "cron", "cron": "0 18 * * FRI", "prompt": "Summarize what we worked on this week and post to Telegram.", }, ]