Repository navigation
ADR 0075 Chat Control Assistant Add On
Accepted (2026-11-01).
Users want to tell MowDeck what to do in their own words: "mäh morgen früh nur hinten", "schick ihn zur Station", "pausier bis 15 Uhr". The AI provider a user configured under Settings, AI provider (ADR 0033) already reads camera pictures for AI assist; the same provider can read a sentence.
Binding requirements of the owner:
- An optional add-on, not woven into the core. Its own service, off unless enabled (Compose profile, Helm value), its web UI only where it is deployed. The core services neither import nor know it, and removing it leaves everything else working. It talks to the rest only through public contracts with the caller's own authorization: no new privileged path.
- Provider neutral and capability aware. The model may only pick from the mower's real actions and areas and the actions the caller may use; its answer is validated server side, and user text and model output are untrusted (prompt injection must not widen the action set).
- Nothing runs without confirmation. A confirmed proposal goes through the existing command and schedule paths, so authorization per command kind (ADR 0016), plans, the controller's safety rules (ADR 0012) and the audit apply unchanged.
- No manual driving by chat, no settings by chat in this version.
- Demo mode (ADR 0068) calls no external AI.
Where the feature lives.
- Inside Vision or Fleet: least code, but it couples a convenience feature to core services and violates requirement 1.
- A new service on the bus (its own NATS user, ADR 0074) that reads mower state from NATS and issues commands through
fleet.commands.issueon the user's behalf: it would need a NATS user with request rights on Fleet's internal subjects, which is exactly a privileged path that bypasses the public API. - A new HTTP-only service that reads the public REST API with the caller's bearer token (the token the web app sent it), like any other client.
How it reuses the AI provider.
- Its own provider settings: a second place for keys and a second settings page.
- A shared library with the LiteLLM client, plus a way to fetch the user's decrypted key from Vision: keys would leave Vision, and the fetch would be a privileged back door.
- A defined interface on Vision: a generic, text-only "structured answer" endpoint on the caller's own effective provider. Vision keeps the keys, the resolution (own provider, else the installation's), the limits and the cost accounting.
Who executes a confirmed proposal.
- The assistant, after a "confirm" call: it would need write paths and become a second place that issues commands.
- The web app, from the structured actions of the proposal, through the same endpoints its buttons use.
Option 3, option 3 and option 2: a separate, HTTP-only, read-only service services/assistant (created from templates/service), Vision's new POST /vision/me/provider/structured for the model call, and execution by the web app after the user confirmed.
web app ──POST /assistant/mowers/{id}/proposals (user token)──▶ assistant
│ same token, public API only
┌─────────────────────────────┬──────────────────────┼────────────────────────────┐
▼ ▼ ▼ ▼
Identity POST /me/permissions Fleet GET /mowers/{id} Telemetry GET map, Vision POST /me/provider/
(can_view, can_control, (name, area names) capabilities, state structured (the user's
can_edit_schedule) provider; no key leaves)
web app ◀── proposal (validated actions) ── assistant
web app ── user confirms ──▶ Fleet POST /mowers/{id}/commands | Scheduler POST /schedules (kind once)
-
No NATS user, no database, no secret. The assistant sets
MOWDECK_NATS_ENABLED=falseand gets none ofMOWDECK_NATS_URL,MOWDECK_DATABASE_URLor OpenFGA settings. So ADR 0074's permission map is unchanged: an HTTP-only service needs no NATS user, which is the better choice than adding one (noNATS_PASSWORD_ASSISTANT, nothing to rotate, no subject it could misuse). - The caller's own authorization. The assistant verifies the bearer token locally (JWKS, like every service) and forwards it unchanged to Identity, Fleet, Telemetry and Vision inside the network. It can never see or do more than the user can in the web app.
-
The core does not know it. No core service imports it or names its API. Identity lists its descriptor only through
IDENTITY_EXTRA_MODULE_URLS(Compose) or whenservices.assistant.enabled(Helm, the chart's own wiring); the web app shows the chat box only when Identity announces the assistant's UI area/assistantand the assistant answersGET /assistant/status.tools/repo-checks/tests/test_addon_isolation.pyenforces all of this. - Two generic core additions, both useful without the assistant and neither mentioning it: Vision's structured-answer endpoint and the Scheduler's one-off schedule kind (below).
| Kind | After confirmation the web app sends | Relations (checked by the catalog, again by the endpoint) |
|---|---|---|
start_now |
POST /fleet/mowers/{id}/commands start_mow with area_ids
|
can_control |
start_later |
POST /scheduler/schedules, kind: once, in the user's time zone |
can_control and can_edit_schedule
|
pause |
pause_mow |
can_control |
resume |
resume_mow |
can_control |
dock |
return_to_dock |
can_control |
That is the whole list. Manual driving, spot runs, the emergency stop, firmware and every setting are not kinds, so neither the model nor the validator can produce them.
For each request the assistant builds a catalog from the mower's real state on the caller's token: the kinds the caller may use (relations), only when the mower ever connected (it reported capabilities), starts only with map areas, immediate kinds only in a fitting activity (pause while mowing or returning, resume while paused, dock unless docked or charging, start_now when idle, docked or charging; an unknown or offline activity offers everything and leaves the decision to the controller). The areas carry their user-visible names (Fleet's custom name, else the mower's).
From the catalog it generates a strict JSON schema (every object closed, every property required): kind is an enum of the offered kinds, area_ids an enum of the mower's area ids, plus start_at (local YYYY-MM-DDTHH:MM), duration_minutes and notes (an enum: manual_drive, settings, timed_resume, other_mower, unsupported, unclear). The answer has no free text: nothing the model writes is ever shown or executed except these codes.
The request goes to the model as one JSON document in the user message (request, mower, areas, offered kinds, local time, time zone). The system prompt declares everything in it data and never instructions. With nothing offered (a viewer, a never connected mower) the model is not asked at all.
Whatever the model returned (a strict-schema model, a prompt-mode model, or one that followed injected text), assistant.domain.validate checks it against the catalog again:
- the shape (closed objects, strict types, at most 10 actions and notes in the parse);
- every kind is known and offered; areas exist (duplicates dropped, at most 50; an empty list means every area); immediate kinds carry no areas, time or duration;
- a later start has a valid local time at least 2 minutes and at most 7 days ahead, converted to UTC in the user's time zone, and a window of 15 to 720 minutes (default 120);
- at most 3 actions, at most one immediate action and two later starts.
All or nothing: any rejected action leaves the proposal without executable actions, so a partly understood request never runs half of what the user meant. Unknown kinds are never echoed back.
The proposal carries the validated actions with area names, the local and UTC start, and expires_at (5 minutes). The web app shows them as a work order (action, mower, areas, time, window, notes) with "Confirm and run" and "Discard". Only after confirmation does it send each action in order through Fleet or the Scheduler with the user's token, stopping at the first refusal; an expired proposal is never sent. Every executing endpoint authorizes the user again, applies plans and the controller's rules, and records its audit exactly as for a button press.
POST /vision/me/provider/structured (purpose, name, system, text, schema) answers one JSON object from the caller's effective provider: the same resolution as AI assist (own provider, else the installation's with the plan feature ai_recover_hosted, else 409 vision-provider-missing), its own hourly limit per user (VISION_STRUCTURED_PER_HOUR, default 30), at most 2000 output tokens, recorded in the cost accounting as kind structured with the purpose, never the text. LiteLLM gets the schema as strict structured output where the model supports it, else in the prompt (one retry when the answer is no JSON object). The fake family (fake/...) answers the smallest object the schema allows, so development stacks see the shape without a model. Vision validates only that the answer is an object; its content is the caller's to validate.
No shared library was extracted: model calls stay in Vision, the only service that holds provider keys. If another service ever needs its own model calls, extracting the LiteLLM client into a library is the later step.
A later start needs a schedule that runs once. The Scheduler gets the recurrence kind once (anchor_date is the day; next_run_at is null after it ran; creating one in the past is refused). The schedule form offers it too ("Once"), and schedule lists show "Once on ".
MOWDECK_ASSISTANT_INTERPRETER=provider (default) reads requests with the user's AI provider; phrases uses a small deterministic phrase parser (German and English: later starts with day and time words, "nur hinten", dock, pause with "until", resume; driving and settings phrases produce their notes). Its output goes through the same validator. Demo mode always uses the phrase parser, so a demo never calls an external AI. The demo stack itself does not run the add-on (demo stacks take no Compose profiles, ADR 0068); an operator can run it there with the phrase parser.
The unit tests use a fake AI provider that returns scripted answers, hostile ones included; the HTTP adapters are tested against a mocked public API; the Playwright spec runs the confirm flow against the e2e stack, where the assistant runs with the phrase parser. No test calls a real AI API.
GET /assistant/status asks Vision for the caller's effective provider. Without one the chat box explains that chat control reads requests with the user's AI provider and links to Settings, AI provider.
- Manual driving by chat: never. Driving needs the live view and a hand on the stop; a sentence cannot provide either.
- Settings by chat (blade height, speed, patterns, mowing defaults, recurring schedules): a later option. It would add kinds that map to the existing settings endpoints with the same confirmation card; the validation would need each setting's range from the capabilities.
-
Resuming at a time ("pause until 15:00"): the pause is offered, the timed resume is explained as not possible yet. A later option is a Scheduler action
resumefor one-off schedules. - Several mowers in one request, conversation memory, voice input.
- The feature is one optional container:
COMPOSE_PROFILES=assistantplushttp://assistant:8000inIDENTITY_EXTRA_MODULE_URLS(Compose),services.assistant.enabled=true(Helm). Without it nothing changes, and no chat box appears. - The assistant holds no state and no credentials; it scales and restarts freely. Its rate limit (
ASSISTANT_REQUESTS_PER_MINUTE) is per replica; Vision's hourly limit bounds the AI cost per user. - The two core additions are generic: any client may ask Vision for a structured answer with its own provider (a user spends their own key, or the installation's within the plan and the hourly limit) and create one-off schedules.
- A model that misreads a request can only propose wrong actions from the offered list; the user sees the exact actions before anything runs, and the card says that the assistant can be wrong.
- Every new kind needs its row in the table above, its validation, its execution mapping in the web app and tests for hostile answers.
Mirrored from docs/adr/0075-chat-control-assistant-add-on.md at commit fc91850a2b39cb822032acc284bb1e82241af0a8. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit fc91850a2b39cb822032acc284bb1e82241af0a8. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- ADR 0065: Security center and new-login alerts
- ADR 0066: Cloud provider plugins and adapters for more vendors
- ADR 0067: Error code lexicon, provider independent
- ADR 0068: Public demo mode
- ADR 0069: Provider conformance kit and provider package template
- ADR 0070: Virtual zones for mowers without a vendor map
- ADR 0071: Capability matrix generated from the adapters
- ADR 0072: API explorer in the web app, for the public demo
- ADR 0073: Guided tour with coach marks
- ADR 0074: NATS authentication and per-service subject permissions
- ADR 0075: Chat control as an optional assistant add-on
- ADR 0076: Demo sandboxes, one account per visitor
- ADR 0077: Liveness, readiness and routing
- ADR 0078: Batched state ingestion
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- Adding a provider: template and conformance kit
- Adding an error lexicon for a vendor
- Design: identity and authorization
- Design: fleet, telemetry, realtime
- Design: web app
- Web app
- Home Assistant integration
- Camera source bridge protocol
- Fuzzing and property-based tests
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Vendor library updates
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Chat control (assistant add-on)
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Load testing
- Spikes
-
Guides
- Guides
- ESP32 bridge: purchase and setup
- Signing in with a passkey
- Signing in with GitHub
- Error codes in plain words
- Aerial photo under the map
- Signing in with Apple
- Confirming critical actions
- Your security page
- iPhone widgets and the mowing Live Activity
- Apple Watch
- The setup tour
- Apple Home and Google Home (Matter)
- Chat control
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)