Build a personal assistant¶
A personal AI assistant (in the style of OpenClaw or Hermes Agent) is a long-running process that talks to you over a chat channel, acts unprompted (reminders, check-ins), and grows its own skills. AIMU ships three async-first primitives for exactly the pieces that go beyond a single LLM call:
| Need | Primitive |
|---|---|
| Talk over a transport (terminal, chat platform) | aimu.aio.Channel ABC + aimu.aio.CLIChannel |
| Act proactively on a schedule | aimu.aio.Scheduler |
| Author new skills at runtime | aimu.skills.write_skill / make_skill_authoring_tool |
These are library primitives, not an app. A complete single-user daemon that wires them
together lives in examples/personal-assistant/.
Model-agnostic LLMs, multi-agent routing, voice I/O, and persistent memory already exist
elsewhere in AIMU, so they are not duplicated here.
Single user by design
A personal assistant serves one person, so there is no multi-user session keying.
ConversationManager holds the one conversation. Multi-user
routing belongs in a wrapper above the library.
Channels¶
A Channel is a tiny transport ABC: receive inbound messages, send replies.
from aimu.aio import CLIChannel, ChannelMessage
channel = CLIChannel()
async for msg in channel.receive(): # async generator over inbound messages
print("got:", msg.text)
await channel.send("Thanks!", reply_to=msg)
ChannelMessage is plain transport data (text, sender, channel, images, metadata),
distinct from LLM conversation state; text and images map straight onto agent.run(...).
send() accepts either a finished string or an AsyncIterator[StreamChunk] to relay a streamed
reply token-by-token.
The reply_to argument is the seam for network adapters: a Telegram/Slack Channel uses it to
route a reply to the right chat. CLIChannel is single-user, so it ignores it. A network adapter
is a drop-in subclass (constructed via a connect() classmethod factory, like
aio.MCPClient.connect); none ships yet, keeping heavy SDKs out of core.
Scheduler¶
Scheduler runs interval and one-shot async jobs concurrently under one asyncio.TaskGroup. A
job is a zero-argument coroutine factory; bind context (an agent, a channel) with a closure.
import functools
from aimu.aio import Scheduler
async def check_in(agent, channel):
reply = await agent.run("Give the user one short, useful suggestion.")
await channel.send(reply)
scheduler = Scheduler()
scheduler.every(3600, functools.partial(check_in, agent, channel), name="hourly")
scheduler.at(30, functools.partial(check_in, agent, channel)) # one-shot, 30s after start
await scheduler.run() # blocks until scheduler.stop()
A job that raises is logged and, for interval jobs, retried on the next tick: one misbehaving
reminder must not kill the daemon. run() is single-use; build a fresh Scheduler to run again.
Job persistence across restarts is intentionally out of scope (jobs are Python callables).
A scheduler-driven turn produces a message the user never asked for. So that a replayed transcript
can tell it apart from a user-driven turn, tag the exchange with the inert provenance key
(aimu.PROVENANCE_PROACTIVE) before persisting; the reference example does this in its _proactive
handler. See Persist conversations.
Skill authoring (self-improvement)¶
The assistant can write a new skill when it works out a repeatable procedure, so it remembers how
next time. make_skill_authoring_tool returns an async @tool the agent calls; pass it to a
SkillAgent so authored skills are discoverable in the same run.
from aimu.skills import SkillManager, make_skill_authoring_tool
from aimu import aio
skills_dir = ".agents/skills"
manager = SkillManager(skill_dirs=[skills_dir])
author_skill = make_skill_authoring_tool(manager, skills_dir)
client = aio.client("anthropic:claude-sonnet-4-6", system="You are a personal assistant. "
"When the user teaches you a repeatable procedure, call author_skill to save it.")
agent = aio.SkillAgent(client, tools=[author_skill], skill_manager=manager, name="assistant")
author_skill(name, description, body) calls write_skill(...) (which validates the name as a
slug, refuses to clobber, and round-trips through the parser so a malformed file fails loudly)
then manager.refresh() to invalidate the discovery cache.
After refresh(), the new skill is reachable via activate_skill. To also refresh the skill
catalog injected into an in-flight system prompt (so a just-authored skill appears mid-conversation),
call agent.reload_skills() (see the next section); the personal-assistant example does this
automatically when a script is authored.
You can also call write_skill directly (no agent):
from aimu.skills import write_skill
write_skill("format-standup", "Format a standup update.",
"# Standup\n\nThree bullets: Yesterday, Today, Blockers.", skills_dir=".agents/skills")
Scripts in skills (author and run code)¶
A skill can bundle executable helper scripts. AIMU registers every scripts/*.py and scripts/*.sh
in a skill as a {skill}__{stem} tool that runs the script as a subprocess (.py via the current
Python, .sh via bash), with an optional args string forwarded to the script's arguments and a
30-second timeout. This lets an assistant automate a procedure as code and run it as a tool, the
self-improving pattern Hermes Agent uses.
make_skill_script_tool(agent, manager, skills_dir) returns an async add_skill_script tool. After
writing the script it calls agent.reload_skills(), which rebuilds the skills server and appends the
new {skill}__{stem} tool to the agent's tool list, so the assistant can author and run a script
within the same turn:
from aimu.skills import SkillManager, make_skill_authoring_tool, make_skill_script_tool
from aimu import aio
skills_dir = ".agents/skills"
manager = SkillManager(skill_dirs=[skills_dir])
client = aio.client("anthropic:claude-sonnet-4-6", system="You are a personal assistant.")
agent = aio.SkillAgent(client, skill_manager=manager, name="assistant")
agent.tools = [
make_skill_authoring_tool(manager, skills_dir), # author_skill(name, description, body)
make_skill_script_tool(agent, manager, skills_dir), # add_skill_script(skill_name, filename, content)
]
You can also attach scripts non-interactively with write_skill(..., scripts={...}):
from aimu.skills import write_skill
write_skill(
"disk", "Report disk usage.", "# Disk\n\nRun the usage script.",
skills_dir=skills_dir,
scripts={"usage.sh": "#!/usr/bin/env bash\ndf -h\n"}, # .sh files are marked executable
)
Scripts run with full access (no sandbox)
Skill scripts execute as real subprocesses with your user privileges, exactly like OpenClaw and
Hermes Agent. There is no sandbox. Only run an assistant that can author/run scripts with a model
and inputs you trust. (builtin.execute_python is the sandboxed alternative for untrusted code:
no filesystem or subprocess access.) The subprocess also blocks the event loop for up to its
30-second timeout.
Because of that, the example gates the full-access add_skill_script tool with a
tool-approval hook: by default the CLI front end asks for a y/n confirmation in
the terminal before a script is authored (assistant.py's CONFIRM_BEFORE set and
_confirm_in_terminal policy, passed to Assistant.create(tool_approval=...)). Edit CONFIRM_BEFORE
to gate more tools, or pass tool_approval=None to disable.
Wire it into a daemon¶
The pieces compose into one process: a top-level asyncio.TaskGroup runs the channel listener
and the scheduler concurrently; each inbound message runs the agent and streams the reply back;
history is persisted after each turn.
import asyncio
from aimu import aio
from aimu.aio import CLIChannel, Scheduler
from aimu.history import ConversationManager
async def main():
channel = CLIChannel()
scheduler = Scheduler()
conversation = ConversationManager("assistant_history.json", use_last_conversation=True)
# ... build agent with author_skill as above; restore prior messages with agent.restore(...) ...
async def serve():
try:
async for msg in channel.receive():
reply = await agent.run(msg.text, stream=True, images=msg.images)
await channel.send(reply, reply_to=msg)
conversation.update_conversation([dict(m) for m in agent.model_client.messages])
finally:
scheduler.stop() # channel closed (EOF) -> stop the scheduler so run() returns
async with asyncio.TaskGroup() as tg:
tg.create_task(serve())
tg.create_task(scheduler.run())
asyncio.run(main())
The reference app in examples/personal-assistant/ is this, fleshed out: serialize reactive and
proactive turns with a lock (they share one agent), an argparse entry point, and mock-only tests.
It also gives the agent a small fixed set of AIMU built-in tools
(builtin.web + builtin.misc). The built-ins are sync functions, and the async agent dispatches
them through asyncio.to_thread, so they attach to agent.tools with no wrapping. The example is
kept intentionally minimal; selectable tool groups, remote MCP servers, and
persistent memory are capabilities AIMU ships but the example leaves out.
It also supports /stop: it runs each turn as a background RunHandle so the
reader stays free to receive a /stop message, which cancels the in-flight turn (the agent keeps the
partial turn for resume) while the daemon keeps serving.
Run it:
python examples/personal-assistant/assistant.py \
--model anthropic:claude-sonnet-4-6 --reminder-seconds 30
Web front end¶
Because the assistant loop consumes any Channel, a browser UI is just a different Channel
implementation: nothing in Assistant changes. AIMU ships a WebChannel (a Channel over a browser
WebSocket, the WebSocket twin of CLIChannel), and the example wires it to a small
Starlette server (examples/personal-assistant/web_assistant.py).
WebChannel.send relays a streamed reply as token frames + a done frame, and a proactive
(scheduler-pushed) message as a message frame the page styles distinctly. The server runs a pump task (reading the socket into the channel) and
assistant.run() concurrently under one asyncio.TaskGroup; on disconnect the pump feeds a sentinel
that ends receive(), stops the scheduler, and tears down cleanly.
Because the server owns the scheduler via assistant.run(), proactive messages reach the browser
unprompted, the property a request/response UI (Streamlit/Gradio) can't provide without polling.
The agent's stream carries THINKING and TOOL_CALLING chunks alongside GENERATING, so both
channels can surface the model's reasoning and tool calls, not just the final answer. CLIChannel
takes opt-in show_thinking / show_tools flags (off by default to keep the library channel
minimal); WebChannel (same flags) emits thinking / tool frames the page renders as distinct
blocks. The personal-assistant example turns both on, threading AssistantConfig.show_thinking /
show_tools into each channel.
pip install aimu[web]
python examples/personal-assistant/web_assistant.py --model ollama:qwen3:8b --reminder-seconds 20
# open http://127.0.0.1:8000
WebChannel lives in the library (aimu.aio.WebChannel): its socket is duck-typed (send_json /
close), so the adapter carries no web-framework dependency and any app can reuse it. The Starlette
server and HTML page stay app-side (a web server is app-layer judgment), so the example supplies
those. An app extends WebChannel by subclassing it and adding frame types through the public
send_frame seam, the same Channel seam a Telegram or Slack adapter would use.
See also¶
- Use skills: the
SkillAgentandSKILL.mdformat the authoring tool writes to - Use async (
aio): the async surface these primitives live on - Persist conversations:
ConversationManager - Add capabilities the example leaves out: use MCP tools, semantic memory, document memory
aimu.aioAPI reference ยทaimu.skillsAPI reference