A framework for personal AI assistants on your own hardware
Project description
dudamel
A framework for running a personal AI assistant on your own hardware: apps are ordinary typed Python, safety rules are enforced by the framework instead of relied on from the model, and the dashboard keeps working even when the model doesn't.
What dudamel is
dudamel splits an assistant into two planes. The command plane is the
chat loop: a message comes in, the model picks tools to call, dudamel runs
them and feeds results back until it has an answer. The data plane —
the dashboard, widgets, scheduled jobs, database migrations — does not
depend on the model at all. If your LLM endpoint is down, the dashboard
still renders, because widgets are plain async def functions that read
the database directly; nothing about them calls the model.
Apps are code, not configuration:
- a tool is an
async defwith type hints — its signature becomes the JSON schema the model is offered, its docstring becomes the description the model sees; - a widget is an
async defreturning a small typed payload (a stat, a table, or markdown) that the dashboard renders; - a job is a scheduled
async def(cron or interval) that can call the model and push a notification, independent of any chat turn.
Safety is enforced in front of every tool call, not left to a system prompt: confirm gates stop and wait for explicit approval before running anything marked as needing it; the taint rule forces the same approval step onto native mutating tools once a turn has seen output from a less-trusted source (currently: MCP); and per-tier token budgets are checked server-side before each model call, not just documented as a limit.
Quickstart
uvx dudamel new my-assistant
cd my-assistant
uv run dudamel db migrate -m init
uv run dudamel run
dudamel new scaffolds a project with one example app already wired up
(workouts, shown in full below), a dudamel.toml, and a generated web
token in .env. The scaffold is local-first by default — both LLM tiers
point at a local Ollama server, so the whole thing runs offline once
you've pulled a model:
[llm.tiers.standard]
provider = "openai-compatible"
base_url = "http://localhost:11434/v1"
model = "qwen3.5:9b"
[llm.tiers.fast]
provider = "openai-compatible"
base_url = "http://localhost:11434/v1"
model = "qwen3.5:1.5b"
Point a tier at a hosted provider instead by setting provider = "anthropic" and an API key env var. uv run dudamel doctor checks every
configured tier is actually reachable, along with the database, migration
state, and web/Telegram token configuration.
The dashboard comes up at http://127.0.0.1:8787. Log in with the token
dudamel new generated in .env (DUDAMEL_WEB_TOKEN); rotate it any time
with uv run dudamel token rotate, which rewrites only that one line.
The whole example app
This is the complete workouts app the scaffold ships — a database model,
a tool, a widget, and a scheduled job:
from datetime import datetime
from dudamel import App
app = App("workouts", description="Log and review gym workouts")
class WorkoutSet(app.Model, table="sets"):
exercise: str
sets: int
reps: int
weight_kg: float
logged_at: datetime = app.now()
@app.tool
async def log_workout(exercise: str, sets: int, reps: int, weight_kg: float) -> str:
"""Record one exercise from today's session."""
async with app.db() as db:
db.add(WorkoutSet(exercise=exercise, sets=sets, reps=reps, weight_kg=weight_kg))
return f"Logged: {exercise} {sets}x{reps} @ {weight_kg}kg"
@app.widget(title="This week", renderer="stat")
async def week_volume() -> dict:
async with app.db() as db:
from sqlalchemy import func, select
total = (await db.execute(select(func.sum(WorkoutSet.weight_kg)))).scalar() or 0
return {"label": "Weekly volume", "value": total, "unit": "kg"}
@app.job(cron="0 20 * * *")
async def evening_summary() -> None:
text = await app.llm("Summarize today's training", tier="fast")
await app.notify(text)
That's the entire app: a typed model, a tool the LLM can call, a widget the
dashboard renders without touching the model at all, and a nightly job
that summarizes the day and sends a notification. dudamel db migrate
picks the model up automatically — no separate schema file.
Remote access
The dashboard binds to 127.0.0.1 by default: reachable only from the
machine it runs on. Two ways to reach it from elsewhere:
- Tailscale (recommended) — put the machine on your tailnet and reach
the dashboard at its Tailscale address;
dudamel doctordetects a running Tailscale client and prints the address to use. No inbound port needs to be opened, and traffic never leaves your own mesh network. - Telegram — works from anywhere with zero network configuration,
because dudamel's Telegram interface polls Telegram's API outward rather
than listening for inbound connections. Set
DUDAMEL_TELEGRAM_TOKENand an allowed-user-id list indudamel.tomland it works from behind any NAT, on cellular, wherever.
Forwarding a port on your router to expose the dashboard directly to the public internet is not recommended — it puts the dashboard's auth layer directly in front of arbitrary internet traffic, which Tailscale and Telegram's outbound polling both avoid needing to do at all.
Running as a service
A personal assistant is only useful if it's actually running. dudamel new
writes a launchd plist and a systemd user unit into <project>/deploy/,
both with the project's own path already filled in:
- macOS —
deploy/dudamel.plist, loaded withlaunchctl load ~/Library/LaunchAgents/dudamel.plist(after copying it there). Sleep stops everything, though: runcaffeinatewhile it matters, or enable Power Nap / "Wake for network access" in Energy Saver settings so scheduled jobs and Telegram still fire while the machine is asleep. - Linux —
deploy/dudamel.service, a systemd--userunit withRestart=always; enable it withsystemctl --user enable --now dudamel.serviceand runloginctl enable-linger $USERso it keeps running after you log out.
Both templates restart dudamel run automatically if it exits — see the
comments at the top of each file for the exact install steps.
Security
- Confirm gates: a tool registered with
confirm=Truealways stops and returns apending_confirmation_idbefore running, regardless of what the model asked for. The same user has to approve it explicitly. - The taint rule: tool output is treated as data, not instruction. Once
a turn has seen a result from a less-trusted source (an MCP tool), any
native tool call in that turn that isn't marked
read_only=Trueis forced through a confirm gate too — even if it wasn't registered withconfirm=True. A tool with no safety annotation defaults to "mutating" until proven otherwise. - Token budgets:
[llm.budget] daily_tokensindudamel.tomlsets a hard per-day ceiling per tier, enforced before each call — a runaway loop or a misbehaving job can't spend past it. - Every
/api/*route requires a bearer token or an authenticated session cookie;/healthis intentionally unauthenticated (it exists for infrastructure checks) and never returns anything beyond up/down status. - dudamel refuses to bind the dashboard to a non-loopback host unless a web token is configured, so it can't end up unauthenticated on your network by accident.
MCP (experimental)
dudamel can mount external MCP servers as additional tools, configured in code alongside your apps:
orchestrator = Orchestrator(apps=[workouts_app], mcp=["npx -y @some/mcp-server"])
MCP support is experimental. Mounted tools are treated as less trusted
than native ones: their results feed the taint rule described above, tool
names are sanitized and namespaced ({server}__{tool}), and a server that
fails to start or doesn't speak the protocol correctly is skipped with a
warning rather than blocking the rest of the assistant from starting.
Only the stdio transport is supported; a mounted server asking dudamel for
sampling, elicitation, or roots gets an explicit refusal rather than a
hang or a silent no-op.
Testing your apps
dudamel.llm.testing.FakeProvider scripts a model's responses so tool
flows can be tested deterministically, without a real model running:
from dudamel.llm.testing import FakeProvider, fake_text, fake_tool_call
provider = FakeProvider([
fake_tool_call("log_workout", {"exercise": "bench", "sets": 3, "reps": 5, "weight_kg": 100}),
fake_text("Logged it."),
])
Hand provider to Runtime/serve() in place of a real one and drive
your app's tools, widgets, and jobs against a real database (typically
tmp_path and SQLite) — no network calls, no model, fully deterministic
output to assert against.
Why "dudamel"?
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dudamel-0.1.0.tar.gz.
File metadata
- Download URL: dudamel-0.1.0.tar.gz
- Upload date:
- Size: 207.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7e19de242cac2ef2401c7d668c87ee1c0c43aad2d1ce50e09e6b32d88643546d
|
|
| MD5 |
02d32a766b820dd1ce3d7f4a32fe92cc
|
|
| BLAKE2b-256 |
c572da65be20991c459543e8e981b2ad0e9c423c21d817930e4ce95ce9306d3c
|
File details
Details for the file dudamel-0.1.0-py3-none-any.whl.
File metadata
- Download URL: dudamel-0.1.0-py3-none-any.whl
- Upload date:
- Size: 115.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c4814dfd3259ce56e1c39d64afceebb51cb5266b28a6cf76fde90d7777e071e0
|
|
| MD5 |
b5b6e531c718f308e6c7be6fc4b1a8b6
|
|
| BLAKE2b-256 |
9abbd438ffaf9ee49491f0619f5dfc56415329e825c035e514717c8987a97a39
|