Skip to content
Conduit

mcp server

void-agent

Write an agent the way you write code: every capability a tool, every workflow a function, every question straight to the person.

hoyipyik/void-agentcommunitylistedunreviewednot running here yet

Set up

Runs on your computer. Pick your agent and operating system.

This project does not publish an install method we can turn into a config. Follow the README instead.

README

synced Oct 14, 2026

void-agent

 _    __      _     __     ___                    __
| |  / /___  (_)___/ /    /   | ____ ____  ____  / /_
| | / / __ \/ / __  /    / /| |/ __ `/ _ \/ __ \/ __/
| |/ / /_/ / / /_/ /    / ___ / /_/ /  __/ / / / /_
|___/\____/_/\__,_/    /_/  |_\__, /\___/_/ /_/\__/
                             /____/

Session remembers · Turn runs · Model schedules · Tool executes & asks · Human answers · Message wakes.

void-agent lets you write an agent the way you write code: every capability is a tool, every workflow is a function, and every question — an approval or an answer — goes straight to the person from wherever it arose.

  • Everything is a tool, mounted as the agent needs. Workflows, sub-agents, MCP tools, Markdown skills, and the person — the same .tool(...) call mounts any of them.
  • Workflows are code. A plain async function takes typed input and an EventSender and invokes its own tools. No graph DSL — code already expresses control flow.
  • The channel is direct. The stream flows straight out as Vercel AI SDK UI messages. Agents and tools at any depth ask the person for approval or answers through the same channel. The answer returns to the frame that asked, with no parent relay.
  • Approval is mechanical. approval reads this call's validated input and decides in code, before the handler. The person says yes or no; the model never decides.
  • A session is a conversation. The messages are the state — one projection for the UI, one for the model. No task, no journal, no resume.
  • About 2,000 lines, pydantic only. One concept per file; provider SDKs behind optional extras.

The framework

Install

Python 3.12+. Extras: openai, anthropic, mcp — keep the ones you need in the brackets, or none for the pydantic-only core.

Online, nothing to download. pip fetches the wheel from the release:

pip install "void-agent[openai,anthropic,mcp] @ https://github.com/hoyipyik/void-agent/releases/download/v0.1.0/void_agent-0.1.0-py3-none-any.whl"

From a wheel. Download void_agent-<version>-py3-none-any.whl from Releases: pure Python, one wheel for every OS, CPU and Python.

pip install "./void_agent-0.1.0-py3-none-any.whl[openai,anthropic,mcp]"

From source. Clone and install the checkout:

git clone https://github.com/hoyipyik/void-agent && cd void-agent
pip install ".[openai,anthropic,mcp]"       # or `uv sync` to hack on it, then `make check`

The checkout also builds that wheel, with the sdist:

uv build                    # dist/

Usage

A weather agent in a script: one tool that asks Open-Meteo (no key needed), one question, one answer. The model is whichever key is in the environment — OpenAI or an Anthropic-compatible endpoint.

import asyncio
import json
import os
from typing import Any
from urllib.parse import urlencode
from urllib.request import urlopen

from pydantic import BaseModel

from void_agent import Agent, Answer, Llm, Message, tool


class City(BaseModel):
    city: str


def get_json(url: str, **params: object) -> dict[str, Any]:
    with urlopen(f"{url}?{urlencode(params)}") as response:
        return json.load(response)


@tool(description="Current weather for a city.")
async def weather(input: City) -> dict[str, object]:
    found = await asyncio.to_thread(
        get_json, "https://geocoding-api.open-meteo.com/v1/search", name=input.city, count=1
    )
    place = found["results"][0]
    now = await asyncio.to_thread(
        get_json,
        "https://api.open-meteo.com/v1/forecast",
        latitude=place["latitude"],
        longitude=place["longitude"],
        current="temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code",
    )
    return {"place": f"{place['name']}, {place['country']}", **now["current"]}


def model() -> Llm:
    if os.environ.get("ANTHROPIC_API_KEY"):
        from void_agent.providers.anthropic import AnthropicLlm

        return AnthropicLlm("claude-sonnet-5")
    from void_agent.providers.openai_responses import OpenAiResponsesLlm

    return OpenAiResponsesLlm("gpt-6-luna")


agent = (
    Agent(model(), "weather", "answers weather questions")
    .prompt(lambda question: [Message.user(question)])
    .tool(weather)
)


async def main() -> None:
    result = await agent.run("What's the weather in Tokyo right now?")
    assert isinstance(result, Answer)
    print(result.value)


asyncio.run(main())

Models

Three adapters. Each reads its key from the environment and speaks to every endpoint compatible with its API.

AdapterExtraConstructEnvironment
OpenAiResponsesLlm — OpenAI on its Responses API, statelessopenaiOpenAiResponsesLlm("gpt-6-luna", extra={"reasoning": {"effort": "high"}})OPENAI_API_KEY; OPENAI_BASE_URL for another host
OpenAiLlm — any Chat Completions-compatible endpointopenaiOpenAiLlm("qwen3:8b")OPENAI_API_KEY; OPENAI_BASE_URL for another host, e.g. Ollama at http://localhost:11434/v1 with any key
AnthropicLlm — Anthropic and any Messages-compatible endpointanthropicAnthropicLlm("claude-sonnet-5")ANTHROPIC_API_KEY; ANTHROPIC_BASE_URL for another host

For OpenAI itself, OpenAiResponsesLlm; OpenAiLlm is for a server that speaks only Chat Completions (Ollama and its kin). extra is merged into every request as is, so any parameter of the API goes there — and the two APIs spell the reasoning effort differently, each refusing the other's:

OpenAiResponsesLlm("gpt-6-luna", extra={"reasoning": {"effort": "high"}})  # none … xhigh
OpenAiLlm("gpt-6-luna", extra={"reasoning_effort": "none"})  # the only effort tools take here

A key or a host of your own goes in through the SDK's client:

from openai import AsyncOpenAI

OpenAiLlm("qwen3:8b", client=AsyncOpenAI(api_key="ollama", base_url="http://localhost:11434/v1"))

OpenAiResponsesLlm keeps nothing on OpenAI's side (store off): the model's reasoning comes back encrypted and is replayed to the next step of the same run, where it counts as input again; a new run starts from its history, without it. The reasoning a step spends counts in its output.

MCP

An MCP server's tools mount on an agent exactly like local ones. What the server does not bring is the approval: whether a call must be signed by the person is declared on your side, per tool name. It needs the mcp extra.

from void_agent.mcp import McpServer


def must_sign(input: dict[str, object]) -> str | None:
    return f"writing {input['path']} is not reversible"  # a reason, or None to let it run


async def with_files() -> None:
    async with McpServer.stdio(
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"
    ) as files:
        assistant = Agent(model(), "files", "a file assistant").prompt(
            lambda question: [Message.user(question)]
        )
        for capability in files.tools(approvals={"write_file": must_sign}):
            assistant.tool(capability)
        print(await assistant.run("What is in /data?"))
  • McpServer.stdio(command, *args, env=…) runs a server as a subprocess; McpServer.http(url, headers=…, timeout=…) connects to a hosted one, headers carrying its token. Keep the async with open for as long as the agent may run: the connection is a lifecycle, not a call.
  • timeout is the seconds a hosted server may take to be mounted and to answer each call. Without it the waits are the SDK's own, and one stuck call holds a turn for five minutes. A call that runs out fails the run like any broken transport; the model is not told.
  • files.names lists the tools; files.describe(name) is the server's own descriptor; files.tool(name, approval=…) mounts one; files.tools(approvals={…}, prefix="files__") mounts them all, namespaced so two servers can both offer search.
  • files.instructions is what the server said of itself when it was mounted — how its tools go together — or None. The bridge puts it in no prompt: it is yours to pass to with_system, or to leave out.
  • files.fingerprint(name) is the SHA-256 of what the server published for a tool — its name, description, input schema and its read-only and destructive hints — computed at mount from the list it sent. Pin it beside the tool's name and a tool the server changed is a mismatch at the next mount, not a new description nobody read. files.instructions_fingerprint is the same for its instructions. void_agent/mcp/fingerprint.py has the rule, for a server that wants to publish the same values from its side.
  • files.read_only_hint(name) is the server's word on whether a tool only reads: True, False, or None when it said nothing. A hint, never a decision: whether a call is signed is still the approval you declare.
  • A mount that fails raises McpMountFailed, never the SDK's exception group. Three kinds have a road of their own: McpUnauthorized (HTTP 401: the token was refused — get another and mount again), McpNotFound (HTTP 404: nothing serves MCP at that address) and McpUnreachable (nothing answered, or the command is not there). Anything else is the plain McpMountFailed, and .status is the HTTP status when the server gave one. What the SDK raised is the __cause__, for your log.
  • A gated call is put to whoever attends the run (agent.run(…, human=…)). With nobody attending, the turn ends with the card open instead of running it — the model never decides whether a side effect runs.

The terminal UI

There is also a terminal UI, for trying the framework before writing a line and for using an agent day to day. void is the same runtime in a terminal: one binary per platform, nothing to install — the UI, the agents, the MCP SDK and ripgrep inside.

Four ways in.

The binary. Download void-agent-cli-<platform> from Releases — Linux x86_64 and arm64, macOS Intel and Apple Silicon, Windows — and run it:

chmod +x void-agent-cli-macos-arm64 && ./void-agent-cli-macos-arm64

macOS asks once the first time: right-click → Open, or xattr -d com.apple.quarantine void-agent-cli-macos-arm64.

From a checkout. uv installs the CLI's dependencies with the rest:

git clone https://github.com/hoyipyik/void-agent && cd void-agent
make setup                  # uv sync, .env from .env.example
make cli                    # reads .env for the key; the same as `uv run python -m cli`

With your own agents. A folder of them, one module each, any build_agent(llm) -> Agent; ~/.void/agents is read on its own, a workspace when named, and --agent picks the one to start on:

./void-agent-cli-macos-arm64 --workspace ./agents --agent researcher

As your own binary. The same packer the release runs:

make cli-build              # dist/void, this checkout's ripgrep inside; FETCH=1 fetches the official one

On first start it asks for a provider and a key — or reads ANTHROPIC_API_KEY, OPENAI_API_KEY or OLLAMA_MODEL from the environment. /model lists the Anthropic and OpenAI models, then whatever a local Ollama has installed; ← and → set how hard the model under the cursor reasons, along the levels it takes. Sessions and the config live under ~/.void; VOID_HOME moves them.

Three agents come built in; /agent switches:

  • universal — the model, a live plan, reflection, ask_user, and every tool the process mounted: void's own toolbox over the directory you started in (read, list, search with ripgrep, write, edit, move, run), whatever ~/.void/mcp.json names, and whatever skills ~/.void/skills holds. Every mounted tool is a switch in /mcp and /skill: on, signed, or off. The toolbox starts signed: each call shows you a card first. Edit mcp.json while the shell runs: the next /mcp picks it up.
  • weather — below.
  • dummy_weather — the weather agent replayed on a scripted model and canned data: no key, no network, the whole protocol on screen.
  • Yours — a file in a folder; below.

/ opens the command menu: /model, /key, /agent, /mcp, /skill, /session, /new, /clear, /attach <path>, /paste, /status, /help, /quit. Drop a file into the composer or write @path to attach it; ⌘V / ctrl+v pastes an image from the clipboard. ↑ in an empty composer rewinds to an earlier message to edit and resend. Esc stops a turn.

Your own agents

Agents are files, and the one thing a file must have is a build_agent: a function that takes the model and returns the Agent. That function is the registration — there is no list to add to, the folder is the registry. It is a function rather than an Agent at the top of the module because the model is the session's choice (/model), handed in fresh at every turn: an Agent(...) written at module level has no model to be built on, and is not listed. Put such a module under ~/.void/agents, or in any folder you name with --workspace, and /agent lists it: the file's stem is its name, the first line of its docstring the blurb, and every agent in the pool can call every other by name. What the scan does with a folder, in order:

  1. Takes every name.py and every name/__init__.py one level down, by name, skipping anything that starts with _ or ..
  2. Imports it. One that will not import is listed anyway, with the error on its row; the rest go on.
  3. Looks for build_agent. Present, the module is an agent called name; absent, it is a helper — importable by its siblings, never listed.
  4. Reads the builder's signature: build_agent(llm) is handed the model, build_agent(llm, agents) the model and the pool.
  5. Runs every builder once on a scripted model, so a name that is not there, a cycle, or a builder that crashes shows on its row at start. Each turn then builds the chosen one again on the real model.
# ~/.void/agents/writer.py
"""writes a short piece from the notes it is given"""

from void_agent import Agent, Llm


def build_agent(llm: Llm) -> Agent:
    # A sub-agent takes typed input — a string here — as its opening message.
    return Agent(llm, "writer", "writes a short piece from notes", input_type=str).with_system(
        "Write it up in three paragraphs, plainly."
    )
# ~/.void/agents/researcher.py
"""finds sources and hands them to the writer"""

from void_agent import Agent, Llm


def build_agent(llm: Llm, agents) -> Agent:
    return (
        Agent(llm, "researcher", "finds and summarises sources")
        .with_system("Find what the question needs, then hand it to the writer.")
        .tool(agents("writer"))  # writer.py beside it, built on the same model
    )
Select an agent
●  1. universal      built-in        the model, a plan, a question — and whatever MCP you mounted
   2. dummy_weather  built-in        the weather agent replayed on a scripted model and canned data — no key, no network
   3. weather        built-in        Open-Meteo: forecasts, hours, history — ask it anything about the weather
   4. researcher     ~/.void/agents  finds sources and hands them to the writer
   5. writer         ~/.void/agents  writes a short piece from the notes it is given

Pick researcher, ask it something, and its writer call renders in the log like any tool's: the question goes to the model, the researcher hands the notes over, the writer's answer comes back as the call's result. The rules, all of them:

  • A builder that takes a second argument is handed the pool. agents("writer") is the writer beside the caller if there is one, else the pool's by name — every agent loaded, from every folder, built on the same model. agents.mounted is every tool the process mounted (the toolbox, mcp.json, skills) for a sub-agent that should have them; the agent you talk to gets them regardless.
  • A module without build_agent is a helper, never listed; its siblings import it as from . import helper, a sub-folder as from .shared import tools.
  • A name taken by an earlier folder is suffixed, never replaced. Two writer.py are writer and writer-1, both rows with their source, and agents("writer-1") reaches the second from anywhere. The built-in three come first, then ~/.void/agents, then each --workspace in order.
  • What cannot run is on its row, in red, before you pick it: a file that will not import, a name that is not there, a cycle (a → b → a). Every builder runs once at scan on a scripted model, so a broken graph shows at start, not on the first turn.
  • A new file shows on the next /agent; an edit needs a restart. The working directory is never scanned — importing a module runs it, and only a folder you named is yours.

The weather agent

Five thin tools over Open-Meteo — geocode, current, hourly, daily, history — no key, and the model as the scheduler. Each tool is one request; the intelligence is in how the model schedules them, so the questions can be as awkward as you like:

  • Which of Taipei, Osaka and Singapore is coolest this weekend, and will any of them get rain? — three places resolved in one step, three daily calls in the next, then a table.
  • When tomorrow does the wind in Berlin drop below 20 km/h? — hourly for the right date in Berlin's own timezone, then the model reads the hours.
  • How much warmer is London this week than the same week last year? — daily and history side by side, the difference computed in the answer.
  • What's it like in Springfield? — geocode returns several; the agent asks you which, on a card, and carries on with your answer.

The plan updates as it goes; when a result surprises it, it reflects before continuing. cli/agents/weather.py is the whole thing: the tools, the system prompt, build_agent; dummy_weather.py beside it is the same agent on a scripted model.

License

MIT

↑↓ navigate openesc close