Skip to content

Running local commands

Most widgets never need this. A widget that must start a program (a launcher, claude -p "Hello", a script runner) declares each command in widget.json, and the page can only name a declared id. This is the one place a widget runs code outside the sandbox, so it is tightly constrained.

"permissions": {
"commands": [
{ "id": "wake", "run": ["claude", "-p", "Hello"], "description": "Starts the Claude usage window" },
{ "id": "ask", "run": ["claude", "-p", "${prompt}", "--model", "${model}"],
"params": { "prompt": { "maxLength": 300 }, "model": { "choices": ["haiku", "sonnet"] } },
"timeoutSeconds": 90 }
]
}
const r = await umbra.run("wake"); // { exitCode, stdout, stderr, timedOut, truncated, durationMs }
await umbra.run("ask", { prompt: "Say hi", model: "haiku" });

These are checked when the widget loads, by scripts/catalog.mjs, and on install. A violation stops the widget from loading.

  • run is [executable, ...arguments], never a shell line. The executable is a plain program name found on PATH (claude, python) or an absolute local path (C:\Tools\app.exe); no relative paths, no UNC. The page can’t change it.
  • Each argument is a literal, or exactly "${name}" for a declared parameter. A parameter can’t be part of a longer string ("--model=${m}" is rejected), and the executable can’t be a parameter.
  • A parameter is a string, always supplied, at most maxLength characters (default 200, max 1000), without control characters, and must not start with - or / unless it’s one of the fixed choices.
  • If the executable is an interpreter (cmd, powershell, pwsh, wscript, cscript, mshta, node, python, py, bash, sh, wsl, rundll32, regsvr32, msiexec, or a .ps1/.bat/.cmd/.vbs/.js file), every parameter needs fixed choices; free text is refused when the widget loads. A parameter reaches the program as one single argument, so spaces and quotes in it can’t add arguments.
  • .cmd/.bat files only start for commands without parameters. timeoutSeconds is 1 to 120 (default 60).

The program runs as you, with no window, in %APPDATA%\Umbra\data\run\<widget id>, with a scrubbed environment (system variables and PATH only: no tokens, no UMBRA_*). It and its descendants run in a job object and are killed on timeout or when Umbra exits. At most 1 MB of stdout and of stderr is kept (truncated tells you). Calls fail while the widget is disabled, and a running command stops when the widget closes.

The first run shows an Umbra prompt with the exact command line. The approval is saved for this widget and this command, and is void as soon as the command changes or the resolved program file (its path or SHA-256) differs.

  • A prompt only appears right after the user clicked the widget (so a button works; a background timer doesn’t), at most every 20 seconds, and not after three refusals. It closes as a refusal after 2 minutes.
  • Without an approval, a call rejects with a message you should show (“Approve it in Settings > My widgets”).
  • Users approve and revoke each command in Settings → My widgets, and Browse flags widgets that declare commands.
  • Automation can’t approve. umbractl approvals and the MCP list_command_approvals are read-only, and backups never contain approvals.

widgets/script-runner is a worked example.

A widget that runs whatever the user wants sets "userScripts": true in permissions. Its card in Settings > My widgets then gets a Your scripts section where the user adds, edits and deletes scripts (PowerShell, Python, Bash or a program; a file, or code typed right there). The widget lists them with umbra.commands(), re-reads them in umbra.onCommandsChange, and runs them by id with umbra.run.

  • Umbra builds the command (powershell ... -File <script>, python <script>, bash <script>, <program>), never a shell line, and the row in Settings shows it.
  • Saving in Settings is the approval. Only Settings can add, change, delete or approve a script; no umbractl or MCP tool can, and the widget’s page only sees ids and names.
  • Scripts are kept outside the widget folder, so updating the widget keeps them. The same timeout, output cap and environment rules apply.