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.
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, 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, and the framework enforces it — app.llm() raises inside
a widget. Scheduled jobs sit beside the data plane rather than in it: they run
without the chat loop but may call the model. If that call fails the job
records a failed run and the scheduler carries on, and the dashboard keeps
rendering whatever was last stored. To show model output, have a job or tool
compute it, store it, and let the widget read the stored result.
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 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.
uvx dudamel new my-assistant
cd my-assistant
uv run dudamel apps list
uv run dudamel run
dudamel new scaffolds a project with an apps/ directory for your own
apps, a dudamel.toml, and a generated web token in .env. It starts with
no apps enabled: first-party apps that ship with dudamel are switched on in
dudamel.toml under [apps.<name>] (dudamel apps list shows what is
available), and apps you write yourself go in apps/ and are registered in
assistant.py. Once one of your own apps defines models, uv run dudamel db migrate -m init generates the revision for them.
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. Two more keys apply to any tier:
api_key_env(string, no default) — the name of the environment variable that holds the tier's API key; the key itself never goes indudamel.toml. Forprovider = "anthropic"it defaults toANTHROPIC_API_KEY, anddudamel runrefuses to start if that variable is unset. Forprovider = "openai-compatible"it is optional: leave it out for a local server that needs no key (Ollama), set it for a hosted one.max_tokens(integer, default1024) — the most tokens the model may generate in one reply on this tier. Raise it if replies get cut off.
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. It
does not revoke the old token: a running instance read its token at startup
and keeps accepting the old one until it is restarted (systemctl --user restart dudamel.service, or stop and re-run dudamel run), so restart
right after rotating a token you believe has leaked.
This is the complete workouts example from examples/workouts.py — 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. Save a file
like this under your project's apps/ and add it to the list in
assistant.py to run it.
Reads inside app.db() are not isolated. async with app.db() as db:
commits when the block exits cleanly and rolls back on an exception, but it
gives you no isolation for reads: SQLAlchemy's SQLite driver only starts a
transaction at the first write, so a SELECT runs outside any transaction and
another request's write can land between your read and your write. A
read-modify-write built on what you read (row.n = row.n + 1) therefore loses
updates under concurrency. Do the arithmetic in the database instead, with an
atomic statement such as await db.execute(update(Counter).where(Counter.id == 1).values(n=Counter.n + 1)), and back any check-then-insert with a unique
constraint, catching the IntegrityError it raises for the loser of the race.
Dates inside an app. app.now() is a column default and stores UTC. When
you need the operator's day — a "due today", a streak, anything a person reads
as a date — use app.today(), never date.today(): the server's clock is not
the user's, and a habit ticked at 21:00 in UTC−5 lands on the next UTC day, which
makes a perfect streak read as broken. To render a stored timestamp, use
app.in_timezone(value) rather than converting it yourself; calling
.astimezone(...) on a naive column reads the process zone, which is right on
a UTC machine and wrong on every host set to a real location. Both resolve
against the single timezone below.
Give a tool an action= label and it gains a second invocation path with no
model in it — a button on the dashboard, a tap in Telegram (send /home,
see below):
@app.tool(action="Done")
async def archive_note(id: int) -> str:
"""Archive a note."""
...It is still one tool, not two. The model can call archive_note exactly as
before, and both paths coerce arguments against the same signature-derived
schema. The label is the whole opt-in: a tool without one has no UI surface
at all, and the action endpoint answers 404 for it rather than running it.
uv run dudamel doctor prints an action column in its tool table, so which
tools carry a button is visible right next to their safety flags.
There are two ways to put one on a card. A list widget can attach an action
to an individual item, with that row's arguments already filled in:
@app.widget(title="Open notes", renderer="list")
async def open_notes() -> list[dict]:
return [
{
"title": "Buy milk",
"action": {"tool": "archive_note", "args": {"id": 7}, "label": "Done"},
},
]label is optional and overrides the tool's own label for that one row. A
card-level button, by contrast, collects no input, so it may only name a tool
with no required parameters:
@app.widget(title="Notes", renderer="markdown", actions=["archive_all"])
async def recent() -> str: ...Either form may only name an action-labelled tool of the widget's own
app, but the two are checked at different moments, because only one of them
is known before the widget runs. A card-level actions=[...] entry that
names an unknown tool, an unlabelled one, another app's, or one with a
required parameter is a registration error at startup — the assistant
refuses to start. A per-item action is data the widget returns, so it can
only be judged when the widget runs: one naming an unknown, unlabelled or
other-app tool, or whose args don't coerce against that tool's schema,
degrades the widget to an error card — the whole card, its data
included, not just the offending button.
Two rules about widget payloads tightened alongside this. A list item's url
now accepts only http://, https:// and mailto:, and rejects whitespace
and ASCII control characters — a widget returning anything else degrades to
an error card. And a per-item action label is capped at 32 characters, the
same limit a registered action= label has, because both render inside a
button.
By default the dashboard renders every widget in one flat grid: enabled
first-party apps first (alphabetically), then your own apps (alphabetically),
each app's widgets in declaration order. [[home.section]] in dudamel.toml groups them into
titled sections instead, addressing each widget as <app>.<widget>:
[[home.section]]
title = "Today"
widgets = ["workouts.week_volume", "notebook.recent"]
[[home.section]]
title = "Later"
widgets = ["notebook.someday"]Three rules about which widgets go where, each chosen so a configuration mistake degrades rather than breaks:
- an id naming no registered widget is ignored — a widget legitimately disappears when its app is switched off, and that must not blank the page;
- a registered widget named in no section still renders, in a trailing untitled section, so enabling an app can never make its cards invisible;
- no
[home]block at all means the previous flat grid, unchanged.
A widget named twice renders once, at its first mention. Neither of the quiet
cases is left unexplained: dudamel doctor reports a layout id that matches
no widget and a widget listed in more than one section.
The spelling of the keys themselves is the one thing here that is not
degraded, deliberately. A key these blocks do not define — widget for
widgets, or [[home.sections]] for [[home.section]] — is refused when
dudamel.toml is read, naming the key path, and the assistant does not
start. There is nothing useful to degrade to: such a section would hold no
widgets, be dropped as empty, and take a whole group off the page with no id
to report and nothing left to notice it by.
Some local models — especially smaller ones served through an
OpenAI-compatible endpoint — either reject a request that includes tools
or accept it and never emit a tool call. Set tool_calling = "prompted" on
that tier:
[llm.tiers.standard]
provider = "openai-compatible"
base_url = "http://localhost:11434/v1"
model = "some-small-model"
tool_calling = "prompted"This replaces the native wire format with a prompted fallback: tool
descriptions and prior tool results are flattened into plain prompt text,
and the model is instructed to reply with a small JSON envelope instead of
a native tool call. Run uv run dudamel doctor --probe-tools after
changing this — the probe wraps the same way the router will, so a passing
probe means the prompted round-trip actually works for that model, not
just that native tool calling is (as expected) absent.
Trust caveats:
- Tool selection is exactly as gated as the native path — a parsed call still has to name a tool the registry knows about, and an MCP-origin tool still taints the turn the same way whether the call arrived natively or was parsed from prompted text. This setting changes how a call is recognized, not what an accepted call is allowed to do.
- A pending confirmation can never be approved by anything the model outputs, prompted or native — approval is a separate, out-of-band step the user takes through an interface, not something reachable from parsed text.
- The fallback is more permissive about malformed output than a native backend would ever produce: expect it to occasionally reply in plain prose instead of calling a tool it should have. That degrades to a normal text reply, never a crash. If instead it emits a well-formed envelope that asks for nothing runnable — an empty call list, or entries missing a tool name — the reply you see is a short neutral apology rather than the model's raw JSON; the envelope and the reason are logged at WARNING for the operator.
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.dudamel doctorprints that address ashttp://…, and reaching the dashboard over plain HTTP at a non-loopback address is exactly the casecookie_secure's auto-derivation (see below) marksSecure— a browser will not store aSecurecookie on a plain-HTTP, non-localhost origin, which shows up as the login page redirecting back to itself instead of an error. If you're reaching the dashboard this way, set[web] cookie_secure = falseindudamel.toml. - 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. Besides ordinary chat, the bot answers one command:/homesends the same homescreen the dashboard renders as a plain-text digest — a message per[[home.section]](split further if the section outgrows one message), each card's contents as lines, and an inline button under the message for every action on it, numbered so a button names the line it acts on. Tapping one runs that action immediately (aconfirm=Trueaction's label is prefixed⚠️, because a tap is the whole decision). The buttons belong to the message they were sent with and are single-use; an already-tapped or superseded one answers "That button expired — send /home again". Telegram does not offer/homein its command menu unless you add it there yourself via BotFather.
Telegram is configured under [telegram] in dudamel.toml:
token_env(string, default"DUDAMEL_TELEGRAM_TOKEN") — the name of the environment variable holding the bot token from BotFather. Unset means the bot is simply off;dudamel doctorreports that as informational.allowed_user_ids(list of integers, default[]) — the Telegram user ids allowed to talk to the assistant. It is a deny-by-default allow-list, so with it empty nobody can use the bot, and a message from anyone not on it is answered with only their own numeric id (at most once an hour) so you can find the id to add. It is also who scheduled notifications are sent to.allow_groups(boolean, defaultfalse) — whether the bot answers in group chats at all. With it off, group messages get no reply whatsoever. Turning it on does not skip the allow-list: only listed users are served, in a group as in private.
[telegram]
allowed_user_ids = [123456789]Nothing about /home widens who can reach the assistant: the command
answers only the user ids already on the allow-list, and every button
carries a one-shot token bound to the user it was issued to.
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.
The dashboard's login token is read from the environment variable named by
[web] token_env (default "DUDAMEL_WEB_TOKEN", the one dudamel new writes
to .env). Set token_env only if you keep the token under another name.
Running a reverse proxy (nginx, Caddy) in front of the dashboard on the
same host needs one more setting: list its address in [web] trusted_proxies in dudamel.toml, or dudamel ignores X-Forwarded-For
entirely and every request looks like it came from the proxy itself
instead of the real client.
If the proxy terminates TLS and forwards to the dashboard over plain HTTP on the same host, a few settings need attention:
cookie_secure—[web] cookie_secureindudamel.tomlisNone(auto) by default: the session cookie gets theSecureflag whenever the configured[web] hostisn't a loopback address, and stays plain on127.0.0.1/localhost/::1since those are secure contexts to a browser even over HTTP. Settingcookie_secureexplicitly always wins over that derivation, in either direction. WhenSecureis on, the cookie is also renamed to__Host-dudamel_session— a browser-enforced prefix that refuses the cookie unless it'sSecure, scoped toPath=/, and carries noDomainattribute. The derivation looks at[web] host, not at what the browser sees: a proxy that terminates TLS and forwards to a loopback bind leaves[web] hostat127.0.0.1, so the auto-derivation resolves toFalseeven though the deployment is HTTPS end-to-end. In that setup, setcookie_secure = trueexplicitly.dudamel doctorprints the resolved posture — the value, whether it was set explicitly or derived, and (when derived) the remedy for the topology it looks wrong for — right under the dashboard URL it judges it against.trusted_proxies— only ever list a proxy's address here if you actually run it: anything in this list is a peer dudamel will believe can claim any client address viaX-Forwarded-For. Leaving it empty (the default) means forwarded headers are ignored and requests are attributed to whichever address actually opened the connection — the proxy itself, not its clients.- HSTS — dudamel does not set
Strict-Transport-Securityitself; that header belongs to whatever terminates TLS. Set it on the proxy. allowed_hosts—[web] allowed_hostsdefaults to["localhost", "127.0.0.1"]. Every request'sHostheader is checked against this list; a request whoseHostisn't on it gets a400before it reaches any route,/healthincluded. A proxy forwarding its own external hostname will 400 every single request until that hostname is added toallowed_hosts. Do not set it to["*"]to make the 400s go away — that disables the host check entirely and reopens the DNS-rebinding attack it exists to close; add the specific hostname(s) instead.
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.
auto_migrate (a top-level key in dudamel.toml, default true) controls
whether dudamel run upgrades the database schema in place on every
startup. That's convenient for local development, but a service that
restarts on its own — a crash, a systemctl --user restart, a machine
reboot — should not be able to mutate the schema just by starting up. Set
auto_migrate = false for a production deployment. With it off, a
startup against a schema that's behind its migration scripts refuses to
start instead of upgrading, and tells you to run dudamel db migrate -m <message> — which both autogenerates the pending migration and applies
it (core and app schemas), not apply-only.
Backups. On SQLite, whenever a migration is actually about to be applied
(core, a built-in app's tables, or yours), dudamel first copies the database
with SQLite's online backup API (safe in WAL mode) to
<database>.bak-<UTC timestamp> next to the database file, e.g.
dudamel.db.bak-20261029T101500123456Z. A start or db migrate with nothing
to apply takes no backup, a run takes at most one, and earlier backups are
never overwritten; the newest five are kept and older ones are deleted
(only files with exactly that name pattern for this database). To restore
one: stop the instance, copy the chosen backup over the database file, and
delete the database's -wal and -shm sidecar files if present.
App migrations resolve from project_dir (a dudamel.toml/Settings key,
default the current directory) — the same migrations/ that dudamel db migrate writes — not from data_dir. data_dir holds only the
single-instance lockfile (.dudamel.lock); the SQLite database is wherever
database_url points (by default dudamel.db in the directory you run
from). Keep data_dir and project_dir the same unless you deliberately
split them: migrations placed under data_dir are ignored.
- 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, any further mutating
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. Two kinds of tool count as less-trusted: any MCP tool, and any tool registered withexternal=True. Approving a gated call from either taints the rest of that turn as well — its output is untrusted whether it arrived through the gate or not. Where taint is re-derived from history (taint_mode = "window", and a summary's stored taint flag), a call whose tool the registry can no longer resolve — an MCP server dropped from the config, a renamed tool, or a name the model invented — counts as untrusted: unknown provenance is not trusted provenance. external=True: mark a tool whose return value may contain text an attacker controls — anything that fetches a web page, an RSS feed, or a calendar subscription. It is orthogonal toread_only: an external read-only tool still runs without a prompt, but it taints the turn, so a write the page talks the model into is held at the confirm gate for you to approve or refuse (unless you have turned taint off). Taint applies to the turn that fetches, not to the data: if your app stores fetched content and reads it back later, mark the reading toolexternal=Truetoo.- Card actions run without the model: a button click is the human
decision the confirmation gate exists to obtain, so an action is never
queued for confirmation. A tool marked
confirm=Truestill asks first, on the surface the click came from: the dashboard raises a browser dialog before it posts, and Telegram — where one tap is the whole decision and there is no dialog to raise — marks the button's label with a⚠️prefix instead. Nothing on this path can be reached by prompt injection: the model cannot click. An app must construct a typed action explicitly; no external text is ever parsed into one. What an action can do is write attacker-influenced text into your database without tainting anything — anexternal=Truetool may carry an action label, and a click is not a turn, so the taint rule never comes into it. That is intended: the human who clicked is the authority for that call. Judge such a tool by what it writes, the same as any other mutating tool, and mark whatever reads that content backexternal=True. - Confirmation expiry:
[router] confirm_ttl_seconds(integer, default900) is how long a pending confirmation stays approvable. After that it expires and the action is not run; the user has to ask again. A shorter value narrows the window in which a stale prompt can be approved by mistake. - Step limit:
[router] iteration_cap(integer, default8) caps the model round-trips in one turn — each pass lets the model call tools and read their results. A turn that hits it stops with a "couldn't finish within the step limit" reply (or, if an approved action had already run, says so), so a model stuck in a tool loop cannot run indefinitely. - Token budgets:
[llm.budget] daily_tokensindudamel.tomlsets a hard per-day ceiling on tokens spent across ALL tiers combined (it is one global budget, not one per tier), enforced before each call — a runaway loop or a misbehaving job can't spend past it. - Persona:
[router] personaindudamel.tomlcustomizes the assistant's system prompt identity line (the "You are..." part). The installed apps block and tool-use instructions are always present — a persona cannot disable tool use by accident. Leave unset for the default identity. - 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.
Every turn assembles a context window from the conversation's history under
a token budget ([router] window_tokens); once a long-running conversation
doesn't fit, the oldest turns are cut, always at turn boundaries, and the
model simply never sees them. [router] compact_dropped_turns (default
false) turns on a summarizer that condenses the turns a window build is
about to drop into one row in the summaries table, so the assistant keeps
"the gist" of a long conversation instead of silently forgetting it.
Turning it on requires [router] compaction_tier, naming one of
[llm.tiers] — dudamel refuses to start if it's missing or names a tier
that isn't configured.
The summary is prepended to the window as a role="user" message, worded
as background context rather than as an instruction — never as
role="system", because the anthropic provider folds every system message
into the single top-level system request parameter, alongside the
operator's own instructions, and a summary of the conversation's own
history (which can include text an MCP tool put into it) does not belong
there. Its token cost is subtracted from the budget handed to the window
builder, so compaction never adds headroom pressure beyond an uncompacted
turn — it does not guarantee the model call stays within window_tokens
overall: the window builder always includes the newest turn even when
that turn alone exceeds whatever budget it's given, with or without a
summary competing for the same budget.
Summarization runs at most once per turn — not once per loop iteration —
and reuses the newest summary already covering the dropped span instead of
calling the model again. A summarization failure (including a budget
error) is logged and the turn proceeds with the uncompacted window; it
never fails the turn.
Cost: each summarizer call condenses the whole dropped span from
scratch, not "the previous summary plus the new turns". Once a
conversation has outgrown its budget the span grows every turn, so
compaction adds one model call per turn whose prompt grows with the span:
measured on a simulated steady state, roughly 2k characters of prompt by
turn 10 and 16k by turn 60 with small tool results, and ~66k and ~479k
respectively when tool results run at the full tool_result_cap. The
200-message horizon bounds the worst case near 1.6 MB (~400k tokens),
which can exceed a provider's context limit — that failure is logged and
the turn proceeds uncompacted, like any other summarizer failure.
A summarized turn's taint (whether it saw output from a less-trusted MCP
tool) is computed from the summarized rows' own provenance, never from the
summarizer's output, and is carried forward: a new turn seeds its taint
state from the newest summary's flag, under every taint_mode except
"off", including the default "turn" mode.
Scope: ConversationStore.recent() reads at most the newest 200
messages in a conversation. Compaction only ever sees, and only ever
covers, that same window — anything older than the 200-message horizon is
gone regardless of whether compaction is enabled.
dudamel can mount external MCP servers as additional tools, configured in code alongside your apps. A plain string is a stdio command, launched as a subprocess:
orchestrator = Orchestrator(apps=[workouts_app], mcp=["npx -y @some/mcp-server"])For an HTTP server, or a stdio server that needs its own environment
variables, use MCPServerConfig instead of a string:
from dudamel import MCPServerConfig
orchestrator = Orchestrator(
apps=[workouts_app],
mcp=[
MCPServerConfig(
url="https://mcp.example.com/mcp",
headers={"Authorization": f"Bearer {token}"},
),
],
)MCPServerConfig takes exactly one of command (a stdio command string)
or url (a streamable-HTTP endpoint) — never both, never neither.
headers are sent with every HTTP request, which is the only way to reach
an authenticated server: the underlying client sends no headers of its
own, so a bare URL string can't carry a token. env names environment
variables (already set in your own process) to pass through to a stdio
server's subprocess, per server — this replaces the plain-string form's
global [mcp] env_passthrough list for that one 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. A
tool a server annotates destructiveHint: true is registered with
confirm=True, so it gets a confirm prompt on every call, the same as a
native tool registered that way. A mounted server asking dudamel for
sampling, elicitation, or roots gets an explicit refusal rather than a
hang or a silent no-op. If a mounted server pushes the tool count past
[router] max_tools, nothing is dropped permanently: every native tool
stays offered on every turn, and each turn instead picks a relevant
subset of the mcp-origin tools — ranked by overlap between the tool's
name/description and the current message, plus any mcp tool already
called earlier in the same turn — to fill the remaining slots. Startup
still refuses to start if native tool registration alone exceeds
max_tools, since that's the operator's own code to fix; a busy mcp
mount just means a given turn won't see every mounted tool, logged once
at mount time and once per turn when a tool is left out — a turn that
suspends on a confirm prompt and later resumes logs at most once for each
half, since the resumed half can be offered a different subset.
A server whose connection dies is reconnected automatically, but only
within limits: a bounded number of attempts with growing backoff. If that
burst is exhausted the server's tools fail fast for a cooldown period
rather than being disabled for the rest of the process — the next call
after the cooldown gets a fresh burst, because a real deployment being
restarted or rolled routinely takes longer to come back than one burst
spans. If a mutating tool's call dies mid-flight — the connection drops,
or the call outlives call_timeout — dudamel reports the outcome as
unknown rather than as a failure: the side effect may already have
happened, and reporting failure would invite a retry that runs it twice.
That wording is aimed at the model, so what actually keeps one approval to
one execution is the confirm gate in front of it, and that gate is
conditional: a tool the server annotates destructiveHint: true is always
gated, but an unannotated one is gated only once MCP output has already
been seen (in the turn, or in the window — that's what [router] taint_mode selects), and not at all under taint_mode = "off". Ungated,
the model is free to call the tool again on its own.
Two [mcp] settings in dudamel.toml control how long this is allowed to
take: call_timeout (default 30 seconds) bounds a single tool call, and
mount_timeout (default 15 seconds) bounds connecting to and listing
tools from a server at startup and on each reconnect. Both must be
positive; anything else is rejected at startup.
Three more size the reconnect burst itself: reconnect_attempts (default
3) is how many connection attempts one burst may spend,
reconnect_backoff_seconds (default 0.5) is the delay before the second
attempt and doubles for each one after it, and
reconnect_cooldown_seconds (default 60) is how long that server's tools
fail fast after a burst is exhausted, before the next call earns a fresh
burst. All three must be positive; anything else is rejected at startup.
dudamel ships a small suite of first-party apps. They are off by default; add a
[apps.<name>] block to dudamel.toml to enable one. The block's presence is
what enables it — there is no separate list to keep in sync.
[apps.tasks]
enabled = true
horizon_days = 1 # how far ahead the Today card looks; 1 = today and tomorrow
[apps.notes]
enabled = true
recent_limit = 5 # how many notes the Recent card shows
[apps.habits]
enabled = true| App | What it gives you |
|---|---|
tasks |
Add tasks with optional due dates; a Today card with a Done button on each row |
notes |
Save and search notes by substring; a read-only Recent notes card |
habits |
Track daily habits; a Habits card whose button is Tick or Undo depending on today |
Run dudamel apps list to see what is available and what you have enabled, and
dudamel db migrate after enabling one — each app owns its own migration lane,
so enabling notes creates only the notes table.
Both the web dashboard and Telegram's /home render the same cards in the same
order, driven by [[home.section]]:
[[home.section]]
title = "Today"
widgets = ["tasks.today", "habits.today"]
[[home.section]]
title = "Archive"
widgets = ["notes.recent"]A widget you do not list still appears, in a trailing untitled section — so
enabling an app can never make its card silently invisible. A widget id naming
an app you have not enabled is skipped rather than fatal, because that is
exactly what happens when you turn an app off. dudamel doctor reports ids that
match no widget.
search_notes is plain substring matching over a note's title and body. Case
is ignored for ASCII letters only: SQLite's lower() and LIKE fold nothing
else, so école does not find École. (Folding in Python instead would mean
loading every note, since the query has no row limit.) There is no ranking and no stemming, and a multi-word query matches
the whole phrase rather than the individual words. For one person's notes this
is enough; if it stops being enough, that is a signal for a real index rather
than a bigger LIKE.
dudamel has one timezone, used by the scheduler and by every app that needs a
local date. It is a top-level key in dudamel.toml — there is no section
wrapping it, so it goes at the top of the file, above the first [section]
header:
timezone = "Pacific/Auckland"
[llm.tiers.standard]
# ...TOML has no way to tell you when you get this wrong: a bare key placed after a
[section] header belongs to that section, so a timezone appended to the end
of the file is read as web.timezone, which dudamel ignores in silence. The
generated dudamel.toml starts with a section, so there is no existing
top-level key to put it beside. Run dudamel doctor afterwards — it prints the
zone it actually resolved, which is how you confirm the key landed.
Leave it unset and dudamel uses the host's zone. That is what the scheduler has
always done — despite what earlier versions of this file said — so an existing
install's jobs keep firing at the same moment. dudamel doctor prints the zone
it resolved and whether it came from your config or from the host. If $TZ is
set in the shell you ran doctor from, the host zone comes from it and doctor
warns: the shipped service units do not set $TZ, so the service reads
/etc/localtime and may use a different zone. Pin timezone to remove the
doubt.
Daylight saving. A cron job scheduled inside the hour that repeats on a
fall-back day fires twice. One scheduled inside the hour that is skipped on a
spring-forward day still fires, but an hour late in wall-clock terms. This is
cron's semantics rather than a dudamel behaviour, and coalesce does not merge
two fires an hour apart on a scheduler that is up. For a job that must run
exactly once a day, pick an hour outside 01:00–03:00.
Budgets. llm.budget.daily_tokens resets at midnight in this zone.
tasks and habits used to take their own timezone setting, defaulting to
UTC. It has been replaced by the top-level key, and dudamel will refuse to start
while the old one is present, so that this change cannot happen silently.
Two things move when you migrate, and they cannot both be preserved:
- Your day boundary. If you never set the per-app value, your habit ticks
were recorded against UTC days. Leaving
timezoneunset moves that boundary to your host's zone, and existing rows keep the old one — so a streak can read short, and there is no way to convert them, because a recorded day no longer knows what instant produced it. Settingtimezone = "UTC"keeps every streak exactly as it was. - Your cron schedule. Jobs already fire in the host's zone. Setting
timezoneto anything else moves them by the difference, and pins them to a zone with its own daylight-saving behaviour.
Pick whichever matters more to you; there is no setting that preserves both.
dudamel doctor prints which zone your days are currently cut at and whether
that came from your config or from the host. It cannot tell you whether your
boundary moved — a recorded day is a date, so nothing in the database says
which zone produced it — so the decision above is yours to make from what you
know about your own install.
One-off budget effect. daily_tokens counts spend since the start of the
day, and that start moves from the last UTC midnight to the last local
midnight. The window is still a day long either way — what changes, once, is
which calls fall inside it.
Which direction it jumps depends on the hour you restart, not on whether you are east or west of UTC. A US-Eastern install restarting at 22:00 local moves its window start back 20 hours, so nearly a full extra day of spend is suddenly counted and a busy install can be over its cap on the first call. The same install restarting at 08:00 local moves the start forward 4 hours, which drops spend out of the window and acts as a partial reset. It settles the next day.
[llm.budget] daily_usdhas been removed and is no longer accepted. If yourdudamel.tomlsets this key, it will raise a validation error on startup. Usedaily_tokensfor budget enforcement — it is the only enforced limit in v1 and later.- An MCP tool a server annotates
destructiveHint: trueis now registered withconfirm=Trueautomatically, so it gets a confirm prompt on every call the same as a native tool registered that way. Previously this annotation was ignored. If you rely on such a tool running without a confirmation step, register it explicitly or adjust the server's annotations. - A
listwidget item'surlis now restricted tohttp://,https://andmailto:, and rejects whitespace and control characters. A widget returning any other url — a relative path included — now renders as an error card instead. See "Buttons: running a tool without the model". - An action label —
@app.tool(action=...)or a list item'slabeloverride — now has C0/C1 control characters,U+2028/U+2029and the bidi overridesU+202A–U+202E/U+2066–U+2069stripped out before the 32-character cap is applied. A label built only from those characters is now rejected as empty, and one that fit the cap only because of them is now stored shorter. - The dashboard's markup changed in two ways that affect custom CSS or
anything scraping the page.
data-widget-idnow carries the qualifiedapp.widgetid rather than the bare widget id. And#widget-gridis no longer itself the grid: it holds onediv.gridper section, each optionally preceded by anh2.section-title, so cards now sit one level deeper. A selector like#widget-grid > .cardneeds to become#widget-grid .card. - A
timezonesetting on atasksorhabitsblock now stops startup with an error: the key moved to one top-leveltimezoneindudamel.toml. The choice it forces — and what it does to existing streaks, cron times and the token-budget window — is spelled out in "Upgrading from 0.1.x" above. - The taint rule now covers MCP mutations. A mutating MCP tool (one the server
does not annotate
readOnlyHint: true) is held at the confirm gate once the turn has seen untrusted output, and also when the same batch of tool calls contains another untrusted call — two calls to MCP tools, say, or one to anexternal=Truetool. Before, only a native mutating tool was gated; an MCP write ran unconfirmed. A workflow that chains an MCP read into an MCP write in one turn now asks first. POST /api/chataccepts only channels that start withweb:and answers400for any other. Thechannelfield used to be an arbitrary string, so a client posting to e.g.telegram:<id>reached that user's conversation. The dashboard itself only ever sendsweb:default.- Text that reaches a card is now cleaned, and a title is capped. Widget
titles, stat labels, units and string values, table columns and string cells,
list-item titles and subtitles, and a widget's error text have C0/C1 control
characters,
U+2028/U+2029and the bidi overrides stripped, and are truncated to 256 characters with a trailing…. Markdown widget bodies lose the same characters but are not truncated. A list-item title that is empty after stripping is rejected; an empty subtitle is dropped. - Local apps may not take the name of a first-party app.
tasks,notesandhabitsare reserved (asdudamelandcorealready were): a project that defines its ownApp("tasks", ...)now fails to resolve with a "name reserved by the first-party suite" error. Rename your app — its tables are prefixed with its name, so a shared name would also collide in the database. - Widget order on the dashboard and in Telegram's
/homeno longer follows the order of yourassistant.pylist. Enabled first-party apps come first, alphabetically by name, then your own apps, alphabetically by name; widgets keep their declaration order within an app. Pin the order you want with[[home.section]]. - Dependency floors went up, and
mcpmoved a major.mcpmust now be>=2,<3(1.x is no longer supported: 2.0 renamed the client request context and moved attributes to snake_case). Other floors:pydantic>=2.12(was 2.7),pydantic-settings>=2.3(2.2),sqlalchemy>=2.0.31(2.0.30),alembic>=1.16(1.13),apscheduler>=3.10.2,<4(3.10),python-telegram-bot>=21.4(21).tzlocal>=5is a new direct dependency. If you pin any of these in a project that depends on dudamel, loosen the pin or move it. - Database backups are now written as
<database>.bak-<UTC timestamp>and the newest five are kept. They used to be a single<database>.bak, overwritten each time. A.bakleft by an older version is no longer written to or pruned; delete it by hand once you no longer need it. dudamel doctornow exits1when a check showsdudamel runwould not start or a configured feature is broken (an app that fails to resolve, anassistant.pythat cannot import, a missing migration lane, pending migrations withauto_migrate = false, an LLM tier with nobase_urlor unset API-key variable, and so on), and also when run outside a project (nodudamel.toml). It always exited0before, so a script or CI step that ran it for its output alone will now fail on those. States that are normal (Telegram not configured, no Tailscale, a databaserunhas yet to create) printℹand a warning prints⚠; neither changes the exit code.
dudamel.llm.testing.FakeProvider scripts a model's responses so tool
flows can be tested deterministically, without a real model running. Hand it
to Runtime as providers={"<tier name>": provider} — a dict keyed by tier,
not a bare provider — and mark that tier provider="fake" in the settings:
import asyncio
import tempfile
from pathlib import Path
from dudamel import App, Orchestrator, Runtime
from dudamel.config import Settings, TierConfig
from dudamel.llm.testing import FakeProvider, fake_text, fake_tool_call
app = App("gym", description="Workout logging")
logged: list[str] = []
@app.tool
async def log_workout(exercise: str, sets: int, reps: int, weight_kg: float) -> str:
"""Record one exercise from today's session."""
logged.append(exercise)
return f"Logged: {exercise} {sets}x{reps} @ {weight_kg}kg"
provider = FakeProvider([
fake_tool_call("log_workout", {"exercise": "bench", "sets": 3, "reps": 5, "weight_kg": 100}),
fake_text("Logged it."),
])
async def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
settings = Settings(
database_url=f"sqlite+aiosqlite:///{tmp}/test.db",
data_dir=Path(tmp),
# `provider="fake"` marks the tier as test-only; the real object is
# handed in through `providers=`, keyed by tier name.
llm_tiers={"standard": TierConfig(provider="fake", model="fake")},
)
runtime = Runtime(Orchestrator(apps=[app]), settings, providers={"standard": provider})
await runtime.start()
reply = await runtime.chat("web:test", "log 3x5 bench at 100kg", user_id="me")
print(reply.text, logged)
await runtime.stop()
asyncio.run(main())This prints Logged it. ['bench']: the scripted tool call was coerced to
typed arguments and run against your real app code, then the scripted text
came back as the reply. It uses a throwaway SQLite file; use tmp_path the
same way in a pytest fixture. The same Runtime drives your widgets and jobs,
so they can be tested against a real database too — no network calls, no
model, fully deterministic output to assert against.
MIT — see LICENSE.