Desktop Console
runspec-console is a desktop application for running, scheduling, and
monitoring your runspec runnables from a graphical UI. It's built on
pywebview with a Vite/React frontend,
discovers every runnable installed in its environment, and can reach runnables
on remote hosts over SSH.
Supersedes runspec-chat
runspec-console replaces the older Chainlit-based runspec-chat UI. Prefer the console for new setups.
Install
pip install runspec-console
runspec-console
On Windows, installing the console automatically pulls in
runspec-windows (with its
[graph] extra), so its Windows system-administration and Microsoft 365
runnables appear in the UI with no extra step. The dependency is gated to
sys_platform == "win32", so it's skipped on macOS/Linux.
To add more runnables, pip install the package that provides them into the
same environment (e.g. runspec-linux) — the console picks them up on the next
launch.
Where runnables come from
The console lists whatever runspec local can discover: any installed package
that ships a runspec.toml and entry points. There's no registry and no
filesystem scanning — pip install (or pip install -e .) is how a runnable
becomes visible.
SSH connections & tuning
The console refreshes every host's connectivity and runnable inventory on a
background timer. To avoid hammering bastions/jump hosts with a burst of fresh
SSH handshakes (which trips sshd MaxStartups and surfaces as "Error reading
SSH protocol banner"), it keeps one reused, keepalive'd SSH connection per
host — shared by the connectivity probe, discovery, history, logs, and
invocations. A warm connection serves new commands over fresh channels with no
new handshake; a host going offline is detected on the next cycle and retried
with exponential backoff.
All knobs live in %APPDATA%\runspec-console\config.toml and have safe
defaults — you only need them for unusual topologies (very strict bastions,
large fleets, high-latency proxies):
[ssh]
# Connection reuse (issue #104). Set false for legacy connect-per-call.
pool = true
keepalive = 30 # seconds; transport keepalive (0 disables)
max_sessions = 8 # max concurrent channels per connection (< sshd MaxSessions)
idle_ttl = 300 # seconds before an unused connection is closed
# Connect-phase timeouts (raise these behind slow proxies / VPNs).
connect_timeout = 10
banner_timeout = 30
auth_timeout = 30
[refresh]
interval = 30 # seconds between refresh cycles
max_concurrent = 5 # max simultaneous NEW handshakes across the whole fleet
jitter = 3 # +/- seconds of per-host start jitter
backoff_base = 5 # seconds; per-host exponential backoff after a failure
backoff_max = 300 # seconds cap
Behind a corporate proxy / middlebox
If a connection error mentions "the remote sent non-SSH data on connect",
a proxy or TLS middlebox is intercepting the SSH port. Point the console at
an HTTP CONNECT proxy with [ssh] proxy = "http://proxy.corp:8080", or set
use_ssh_config = true to honour a ProxyCommand from ~/.ssh/config.
Microsoft 365 runnables (Outlook · Teams · Calendar · OneDrive)
These ship in runspec-windows and authenticate as the signed-in user via the
Microsoft Graph device-code flow (no client secret). One-time setup:
- Register an app in your Entra (Azure AD) tenant (App registrations → New registration).
- Authentication → "Allow public client flows" → Yes — device-code login needs this.
- API permissions → Microsoft Graph → Delegated: add
User.Read,Mail.Read,Chat.Read,Calendars.Read,Files.Read.All, then grant consent. - Copy the Application (client) ID and Directory (tenant) ID.
-
Drop them in
%APPDATA%\runspec-windows\graph.toml(the client id is not a secret):client_id = "00000000-0000-0000-0000-000000000000" tenant = "yourcompany.onmicrosoft.com" -
Run
graph-loginonce (it prints a URL + code to authenticate); the token caches and refreshes silently thereafter.
Trying it at home
For a personal Microsoft account, register the app for "personal
Microsoft accounts" and set tenant = "consumers" (or "common") instead
of a tenant domain.
Admin consent at work
Chat.Read (Teams) is typically admin-consent-required, so a tenant
admin may need to grant consent on the app registration. The
mail/calendar/files scopes are usually user-consentable.
The full app-registration walkthrough lives in the runspec-windows README.
AI assistant (optional)
The console can drive runnables through an LLM. Install the provider extra you want:
pip install "runspec-console[anthropic]" # Claude
pip install "runspec-console[openai]" # OpenAI
pip install "runspec-console[bedrock]" # Claude via AWS Bedrock
White-labeling
If you wrap the console in your own distribution — a package that ships a custom
LLM adapter (see the
provider-plugin example)
and depends on runspec-console — the app brands itself to your package when
your provider is active. It's derived from the installed package's metadata;
no config file or deployment step is required.
| Surface | Derived from |
|---|---|
| Window / page title | your distribution name, prettified (mycorp-console → "Mycorp Console") |
| Documentation link | a Documentation (then Homepage) URL in your package metadata |
| Update check | your package — so the "update available" badge tracks your releases against the venv's configured index |
The branding follows the active [llm] provider: an entry-point provider
resolves to the distribution advertising it; a dotted-path provider to the
distribution owning that module. A built-in provider (anthropic/openai/bedrock/
langserve) or no provider leaves runspec's own identity in place.
Declare the URLs in your wrapper package's pyproject.toml:
[project.urls]
Documentation = "https://docs.mycorp.com/console"
Homepage = "https://mycorp.com"
# Optional: a branded launcher alongside `runspec-console`, created by your
# package (entry points are static per-distribution). It runs the same console.
[project.scripts]
mycorp-console = "runspec_console.app:main"
To override the documentation link explicitly (e.g. an internal mirror),
regardless of package metadata, set it in config.toml:
[console]
docs_url = "https://docs.internal.corp/console"
Event triggers (Windows)
Triggers let the console react to things that happen on the machine and run runspec runnables — a new email, a meeting reminder, a Teams/Slack notification. The Triggers tab shows your configured triggers and a kanban board where each matched event flows across columns: Triggered → Triage → Awaiting human → Running → Done / Failed.
A trigger binds an event source + event type (optionally narrowed by a
regex match) to an action. The action is either a deterministic runnable
call (mode = "rule") or a headless agent turn (mode = "agent") that follows
the trigger's rules with the full runnable toolset. Either way, every runnable a
trigger drives goes through the same autonomy gate as chat — confirm-by-default,
so a card parks in Awaiting human with inline Approve / Deny.
Triggers are a Windows feature today and only fire while the console is running (they never replay a backlog — see forward-only below). An LLM provider must be configured (above) for
mode = "agent"triggers.
Event sources
Source (source =) |
Events (event =) |
Notes |
|---|---|---|
outlook |
new_mail, reminder |
Uses the signed-in desktop Outlook client over COM — no extra dependency, and it rides your existing SSO session. |
notifications |
toast |
Every system toast (Teams, Slack, anything) via one listener. Needs the notifications extra and a one-time Windows consent grant. |
pip install "runspec-console[notifications]" # enables the toast source
The Triggers tab shows each source's availability and the reason any is off
(not on Windows, winrt not installed, consent not granted, …).
Configuring triggers
Triggers live in runspec_triggers.toml in the app config directory
(%APPDATA%\runspec-console\). Give each trigger its own named table,
[triggers.<name>], with its sub-tables namespaced under the same name
([triggers.<name>.match], [triggers.<name>.map], [triggers.<name>.reply]) —
the same shape as runspec.toml's named runnable sections. The table name is
the trigger's name, so there's no separate name key:
[triggers.vip-outage-triage]
source = "outlook"
event = "new_mail"
mode = "agent" # "agent" (LLM-in-the-loop) | "rule" (direct runnable)
autonomy = "confirm" # gate level (default: confirm)
post_to_chat = false # also mirror this run into the Console tab
# agent mode: the rules the model follows for this trigger. (A scalar key, so it
# must appear BEFORE the [triggers.<name>.*] sub-tables below.)
rules = """
If this reports an outage, identify the affected host, run the diagnostics
runnable against it, and draft a short reply summarising findings. Never
restart a service or send mail without confirmation.
"""
# Cheap regex pre-filter on the event payload — all patterns must match. Keeps
# the LLM out of irrelevant events (and tames a chatty `reminder` source).
[triggers.vip-outage-triage.match]
from = ".*@bigcustomer\\.com"
subject = "(?i)outage|down|urgent"
# Optional: let the agent save a suggested reply to Outlook Drafts (never sends).
[triggers.vip-outage-triage.reply]
draft = true
style = "Concise, professional. Acknowledge and state the next step."
Why named tables? Each trigger and its sub-tables are grouped under one unambiguous prefix, so a second trigger can't accidentally absorb the keys of the one above it. Within a single trigger's table, scalar keys (
mode,rules,run, …) must still come before that trigger's sub-table headers — a key written after[triggers.<name>.match]is parsed into the match table (e.g.rulesbecomestriggers.<name>.match.rules, emptying your realrulesand adding a phantom match field no event can satisfy, so the trigger never fires). The Triggers tab surfaces a clear error if it spots this.The old
[[triggers]]array-of-tables form is no longer supported (it was error-prone — a bare[triggers.match]sub-table attached to whichever[[triggers]]preceded it). If the app finds one, the Triggers tab shows a message telling you to switch to the named form.
For a deterministic, no-LLM trigger, use mode = "rule". Two pieces:
run = "host__runnable"— which runnable to run.hostis a host name from your sidebar (uselocalfor the local machine);runnableis the runnable's name. So a runnablerecord-ticketon the local machine isrun = "local__record-ticket".[triggers.map]— how to fill its arguments. The keys are the runnable's own argument names, and the values are templates pulling from the event payload with{{ field }}. (A literal value with no{{ }}is passed as-is.)
[triggers.log-incoming-ticket]
source = "outlook"
event = "new_mail"
mode = "rule"
run = "local__record-ticket" # host__runnable
[triggers.log-incoming-ticket.map]
# left = the runnable's arg name; right = a template from the event payload
summary = "{{ subject }}"
sender = "{{ from }}"
queue = "support" # a literal (no template) is passed verbatim
So if record-ticket takes --summary, --sender and --queue, the keys must
be exactly summary, sender, queue. If a map key isn't a real arg of that
runnable, the run fails validation (you'll see it in the Event log / --debug).
Check the runnable's args in the Forms tab or runspec local.
The values you can template come from the event payload (below); the run is
gated by the autonomy gate just like agent mode, and (unless ignore_rota) the
working-hours rota applies to rule triggers too.
Event payload fields you can match on / map from:
outlooknew_mail:from,to,subject,body,received,categories,entry_idoutlookreminder:subject,start,location,is_meeting,organizernotificationstoast:app,title,body,text
draft_reply — suggested replies
With [triggers.reply] draft = true on an agent-mode new_mail trigger, the
model can call a built-in draft_reply tool that saves a reply to your Outlook
Drafts folder (the agent's text above the quoted original). The console
never sends mail — you review the draft in Outlook and send it yourself, and
each draft save is confirm-gated. The tool is only offered when both the trigger
enables it and the event is a mail with something to reply to.
Working hours (rota)
So automation only runs while you're around to handle confirms, you can restrict
triggers to a weekly working-hours rota. In the Triggers tab → Working
hours: a master switch, then per-day on/off with a start and end time (local
machine time). Outside those windows, matching events are ignored (dropped,
not queued — you won't get a backlog dump when the window opens). It's off by
default (triggers run anytime until you set it up), and stored as [rota] in
runspec_triggers.toml:
[rota]
enabled = true
[rota.mon]
on = true
start = "09:00"
end = "17:30"
[rota.sat]
on = false
start = "09:00"
end = "17:00"
A single trigger can opt out of the rota (e.g. an off-hours maintenance watcher)
with ignore_rota = true in its [triggers.<name>] table.
Driving the rota from your Outlook calendar. Set source = "outlook" and the
window comes from your calendar instead of the grid — you're on shift whenever
an event whose subject contains shift_marker (default "shift") covers the
moment. Great for rotating shifts: put them in your calendar and the triggers
follow. hold_during_meetings (default off) also pauses while you're in a
meeting / OOO. If Outlook can't be read (closed, or non-Windows) it falls back to
the console grid below, so set one as a safety net. (Note: Outlook's Work Hours
setting isn't used — it isn't exposed in free/busy and is only readable via Graph,
which managed tenants block; hence the marker-event approach.)
[rota]
enabled = true
source = "outlook" # "console" (grid below) | "outlook" (your calendar)
shift_marker = "shift" # on shift when an event's subject contains this
hold_during_meetings = false # also pause during meetings / OOO
[rota.mon] # fallback grid, used if Outlook can't be read
on = true
start = "09:00"
end = "17:30"
Audit timeline
Every kanban card keeps a timestamped timeline of what happened to it — stage changes (triggered → triage → … → done) and each tool the agent ran. Expand a card's timeline to read the full trace; it's recorded on the backend, so even a card that flew through the early lanes leaves a complete record.
Event log
Below the board, the Event log shows every event the engine saw and what happened to it: fired (which triggers ran), skipped (working hours) (it matched but the rota gated it), or no match. It's the first place to look when a trigger doesn't fire:
- the email shows no match → your
matchregex (or thefrom/subject) didn't match — check the trigger's[triggers.match]; - skipped (working hours) → the rota gated it — you're outside the window / off-shift, or the rota's master switch is on when you didn't intend it;
- the email isn't in the log at all → the source never delivered it (e.g.
Outlook's
NewMailExdidn't fire, or it landed outside the Inbox).
Rota times are local machine time, not UTC.
Safe by default
- Confirm-by-default. Trigger-driven runnables use the same autonomy gate as
chat; a
manualrunnable is still never run by the agent. Approve / Deny is surfaced on the card (the Awaiting human column). - Forward-only. Sources only react to events that arrive while the console is running — your inbox / existing toasts are snapshotted at startup and never replayed, so reopening the console doesn't re-handle old messages.
- Kill switch. Toggle any trigger off in the Triggers tab (it stops
immediately); delete it from
runspec_triggers.tomlto remove it.
Output rendering
When a runnable prints JSON, the console renders it instead of dumping raw text:
- a JSON array of objects → a table
- a dict that wraps array(s)-of-objects → titled section tables
(e.g.
{System:[…], Application:[…]}, or scalar metadata plus one array of rows) - a plain dict → a key/value view
- nested objects/arrays inside a row → an expandable sub-table or JSON
Each block has a table/raw toggle and a "copy as table" action. For the cleanest tables, a runnable should emit a top-level array of flat row objects; the console still renders nested shapes gracefully when it doesn't.
Usage
runspec-console # production (bundled UI)
runspec-console --dev # point pywebview at a running Vite dev server
runspec-console --devtools # enable the Chromium inspector in a prod build