AI Chat & Tool-Calling
The differentiator. Implemented in internal/ai.
Model interface
Section titled “Model interface”internal/ai/client.go is a thin OpenAI-compatible client
(POST {base}/chat/completions with tools). This means BYO-model:
| Provider | RMM_AI_BASE_URL / UI Base URL |
RMM_AI_MODEL / UI Model |
|---|---|---|
| OpenAI | https://api.openai.com/v1 |
gpt-4o-mini (etc.) |
| Ollama (local) | http://localhost:11434/v1 |
llama3.1 (etc.) |
| vLLM (local) | http://host:8000/v1 |
served model id |
A local model keeps all command/inventory data on your own infrastructure — the privacy-first posture this project targets.
AI settings can be bootstrapped from .env and then edited by an admin in the
Configuration screen (PUT /api/config). The API key is write-only: the backend
encrypts it in app_settings, updates the in-memory AI client immediately, and
only exposes an api_key_set boolean to the UI.
The loop
Section titled “The loop”internal/ai/chat.go — Engine.Respond(ctx, sessionID, userContent, actor, scopeUser, permissions):
- Persist the user message.
- Load full history + a system prompt that hard-codes the guardrails.
- Loop (max 8 iterations):
- Call the model with the tool catalog filtered by the caller’s permissions.
- Persist the assistant message (including any
tool_calls). - If no tool calls → return the text +
created_job_ids. - Otherwise run each tool, persist a
toolmessage with the result, append it to history, and loop again.
History is replayed verbatim each turn (tool_calls and tool results stored as they came), so the conversation is stateless on the server beyond Postgres.
Permissions & access-scope enforcement
Section titled “Permissions & access-scope enforcement”permissions is the authenticated principal’s permission set. The model only sees
tools allowed by that set, and Execute validates again before running a tool.
Examples: inventory_report requires reports.view, list_patches requires
patches.view, proposal tools require jobs.create, and cancel requires
jobs.manage.
scopeUser is the caller’s user id when they have a restricted access scope
(see 07-guardrails-security). It flows into ToolContext.ScopeUserID, and every
device-targeting tool checks tc.allows(ctx, agentID) before reading or proposing
— so a scoped user’s AI sees and acts on exactly the devices that user can.
Admins/unrestricted users pass an empty scopeUser and see everything allowed by
their permissions. The same permission and scope enforcement applies to the MCP
server (11-mcp).
Tools (internal/ai/tools.go)
Section titled “Tools (internal/ai/tools.go)”| Tool | Required permission | Kind | Description |
|---|---|---|---|
list_devices |
devices.view |
read | List agents (id, hostname, os, arch, status, site_id, group_id) |
list_org |
clients.view |
read | Companies, their sites and departments, with ids — scope-filtered |
get_inventory |
devices.view |
read | Latest inventory for a device |
get_job_status |
jobs.view |
read | Status + result of a job |
fleet_summary |
devices.view (+ jobs.view for recent jobs) |
read | Aggregate counts + recent jobs when allowed |
inventory_report |
reports.view |
read | Fleet inventory (software/hardware/machines) by client/site/department, scope-filtered. Defaults to mode: summary — counts grouped in the database (group_by, match); mode: rows reads records, capped at 1000 — see 15-reports |
export_inventory |
reports.view |
read | The complete inventory report as a CSV download link for the operator; same report_type and scope as inventory_report. The rows never pass through the model |
query_audit |
audit.view |
read | Search the audit trail. Defaults to mode: summary (totals by actor and action family, last 7 days unless since/until are given); mode: entries reads records. Filters: actor, action (or family prefix), target. A scoped account is refused |
export_audit |
audit.view |
read | The matching audit entries as a CSV download link, same filters as query_audit |
list_patches |
patches.view |
read | Missing OS patches on a device (KB/advisory, classification, status) — see 16-patch-management |
list_tasks / list_checks / list_policies |
tasks.view / checks.view / policies.view |
read | Existing monitoring configuration, including which rows are unreviewed AI proposals |
install_patches |
patches.manage |
write | Install patches a person already approved. ClaimApprovedPatches filters to approved identifiers, so an unapproved one installs nothing. There is deliberately no tool to approve a patch |
cancel_job |
jobs.manage |
control | Reject a pending job or request cancel of a running one |
propose_script |
jobs.create |
write (gated) | Create a job in awaiting_approval — never executes |
propose_bulk_deploy |
jobs.create |
write (gated) | Mass deployment: one script across many devices, split into one approval-gated batch per company — never executes, never spans companies |
propose_task |
tasks.manage |
write (gated) | Create a scheduled task disabled and stamped proposed_by — the scheduler selects WHERE enabled, so it never runs until a person enables it |
propose_check |
checks.manage |
write (gated) | Same, for a monitoring check |
propose_policy |
policies.manage |
write (gated) | Create a policy marked as awaiting review. Unrestricted admins only, like the REST handler |
propose_move_devices |
clients.manage |
write (gated) | Propose moving up to 500 devices to a site (and optional department). Changes nothing until a person approves it under Devices ▸ Pending operations |
propose_delete_devices |
devices.delete |
write (gated) | Propose deleting up to 500 devices. The approver must type the device count |
Configuration is proposed too, and here is why
Section titled “Configuration is proposed too, and here is why”Letting the AI create a task would have quietly undone everything above. A
task is a script with a schedule, and the scheduler runs one through
jobs.RunAutomated — pre-approved, dispatched immediately, repeating forever.
Its own comment says “never for AI-authored scripts.” An AI able to create an
enabled task would have been strictly more powerful than one able to propose
a script: same arbitrary code, no approval, and it repeats.
So configuration reuses the kill switch that already existed. A proposed task,
check or policy is written with enabled = false and stamped with proposed_by.
The scheduler’s query already reads WHERE enabled, so the row is inert with no
scheduler change at all. A person reviews it in the console — the existing Tasks,
Checks and Policies screens badge it as AI proposal — and enabling it is the
review.
Two columns rather than one, because they answer different questions:
proposed_by— permanent provenance. It survives acceptance, so months later the row still says an AI wrote it.approved_by— who released it. Empty means nobody has.
“Awaiting review” is therefore proposed_by <> '' AND approved_by = '', and a
CHECK constraint refuses to enable a row in that state. The constraint lives in
the database, not in the tool, because the REST handlers are a second path
into the same rows and a guard written in only one of them is exactly the bug the
column exists to prevent.
Patches: install yes, approve never
Section titled “Patches: install yes, approve never”install_patches acts rather than proposes, and it is safe for a reason that has
nothing to do with the AI: DispatchInstall calls ClaimApprovedPatches, which
filters to identifiers a person already approved. An unapproved id installs
nothing. Approving a patch is the human decision about what enters the fleet, and
there is no tool for it — an AI that could approve and then install would make
the guarantee circular.
Why not trust “the admin asked for it”
Section titled “Why not trust “the admin asked for it””Configuration is not created enabled even for an unrestricted administrator. The model reads inventory, hostnames and alert text — data that comes from the monitored machines. “An admin asked” is not evidence that an admin authored the request, so the gate does not depend on who is asking.
Why the write tools only propose
Section titled “Why the write tools only propose”The model can only read through tools allowed by the user’s permissions and scope.
The only mutating actions it can take are to propose jobs. propose_script calls jobs.Propose(..., requireApproval=true),
which creates a job in awaiting_approval. Nothing reaches an endpoint until a
human approves it via POST /api/jobs/{id}/approve. The tool’s return value
explicitly tells the model the job is pending approval, so it won’t claim
success. This is the guardrail, enforced in code.
propose_bulk_deploy is the same guardrail for mass deployment: it resolves
the targets (device ids ∪ site ∪ department), keeps only the caller’s in-scope
devices, and calls jobs.ProposeBulk, which splits them by company into one
awaiting_approval batch per company (a deployment never spans companies). The
human approves each company batch in the UI; nothing runs before that.
Moving and deleting devices
Section titled “Moving and deleting devices”Moving a device between companies or deleting it is a change the server makes,
not a job an endpoint runs, so there was no approval gate to route it through.
It now has its own: propose_move_devices and propose_delete_devices write a
device_operations row that does nothing by itself (migration 014).
Approving it does not replay the AI’s authority. The server carries it out as the person approving, with their permissions and access scope checked again at that moment:
- a move needs
clients.manage, a deletedevices.delete— the same as doing it by hand; the approval route admits either, and the handler checks the one the proposal needs; - a scoped approver cannot release a proposal that names a device, or a destination, outside their scope — it is not even listed for them;
- a deletion is confirmed by typing its device count, as in the console’s own bulk delete;
- one approval runs it once: the proposal is claimed atomically, and a second approver gets 409.
The proposal’s own checks (the caller’s scope, the department belonging to the site, the 500-device limit) only keep the AI from asking for what its user could not see. They are not what authorises the change.
High-level tools (OS-aware actions)
Section titled “High-level tools (OS-aware actions)”On top of the generic propose_script, the catalog includes semantic,
OS-aware tools (in internal/ai/highlevel.go). The model supplies the
intent; the backend builds the correct, vetted command for the target
device’s OS (Windows/Linux/macOS):
| Tool | Action |
|---|---|
install_software / uninstall_software |
winget / apt / brew per OS |
restart_service |
Restart-Service / systemctl |
install_printer |
Add-Printer (TCP/IP port) / lpadmin |
flush_dns |
ipconfig / resolvectl / dscacheutil |
reboot_device |
shutdown /r / shutdown -r |
These create the same approval-gated job as propose_script (the AI can’t
execute them). Parameters are validated server-side (shell-metacharacter
rejection, IP parsing) as defense in depth on top of the human approval gate.
They’re code-defined (no per-deployment maintenance) and require jobs.create.
Because the MCP server reuses the same filtered catalog, they’re available to
external AI clients only when the connector has that permission. The
generic propose_script remains the fallback for anything without a dedicated
tool. Verified end-to-end via MCP tools/call (correct per-OS command, injection
rejected, job left awaiting approval).
Report generation
Section titled “Report generation”Reports are the model summarizing data fetched via permitted read tools:
fleet_summary, get_inventory, get_job_status, or inventory_report when the
principal has reports.view. Ask for a report in chat and the model assembles it
from tool output.
The fleet and audit tools count in the database by default (mode: summary)
and read individual rows only when asked to, capped. When the operator wants the
data itself, the model calls export_inventory or export_audit, which return a
signed link to GET /api/exports/csv: the rows stream from the database to the
operator’s browser and never enter the conversation, so nothing is truncated or
retyped. The link expires, works only for the account that asked, and is
re-checked against that account’s current access when it is opened (see
04-api-reference).
Reports are read-only and stay in the chat. A system-prompt rule forbids the model from proposing a script that writes a report file on the endpoint: to “report / list / show” data it must use the read tools and answer in chat. If a report genuinely needs a script, the script prints to STDOUT (the output comes back in the job result) — it never writes a file on the device unless the user explicitly asks to save one there.
Downloadable CSV in the chat
Section titled “Downloadable CSV in the chat”When the user asks for a report/export (or mentions CSV/spreadsheet/download), the
model renders the data as a GitHub Markdown table (or a fenced ```csv block).
The chat UI (openrmm/src/screens-chat.jsx) parses the assistant message:
- Markdown tables render as real HTML tables.
- A “Download CSV” button appears under the message; it builds the CSV and
downloads it client-side via a
Blob— nothing is written on the server or the endpoint, and no file artifact is persisted.
This suits a small table the model has already shown. For anything larger, or
whenever the exact contents matter, prefer the export links above: the browser
CSV contains what the model wrote, and the link contains what the database holds. To pull an
existing file off an endpoint instead, use the remote-access file manager
(08-remote-access).
Adding a tool
Section titled “Adding a tool”- Add a
Tool(name, description, JSON-Schema parameters) inTools(). - Add a case in
Execute()that validates args and calls the store/job service. - Keep mutating tools gated behind approval. For new high-level actions
(e.g.
install_printer), have the tool generate a job rather than act directly — same approval path.
