Skip to main content

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 def with 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 def returning 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 doctor detects 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_TOKEN and an allowed-user-id list in dudamel.toml and 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:

  • macOSdeploy/dudamel.plist, loaded with launchctl load ~/Library/LaunchAgents/dudamel.plist (after copying it there). Sleep stops everything, though: run caffeinate while 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.
  • Linuxdeploy/dudamel.service, a systemd --user unit with Restart=always; enable it with systemctl --user enable --now dudamel.service and run loginctl enable-linger $USER so 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=True always stops and returns a pending_confirmation_id before 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=True is forced through a confirm gate too — even if it wasn't registered with confirm=True. A tool with no safety annotation defaults to "mutating" until proven otherwise.
  • Token budgets: [llm.budget] daily_tokens in dudamel.toml sets 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; /health is 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"?

check this out

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dudamel-0.1.0.tar.gz (207.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dudamel-0.1.0-py3-none-any.whl (115.4 kB view details)

Uploaded Python 3

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

Hashes for dudamel-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7e19de242cac2ef2401c7d668c87ee1c0c43aad2d1ce50e09e6b32d88643546d
MD5 02d32a766b820dd1ce3d7f4a32fe92cc
BLAKE2b-256 c572da65be20991c459543e8e981b2ad0e9c423c21d817930e4ce95ce9306d3c

See more details on using hashes here.

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

Hashes for dudamel-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c4814dfd3259ce56e1c39d64afceebb51cb5266b28a6cf76fde90d7777e071e0
MD5 b5b6e531c718f308e6c7be6fc4b1a8b6
BLAKE2b-256 9abbd438ffaf9ee49491f0619f5dfc56415329e825c035e514717c8987a97a39

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page