Prompt Routing & Shell Integration
Doc status: MVP implementation guide for Epic O. Last updated: 2025-11-25.
This guide explains how Runloop classifies interactive prompts (rlp route),
why/when they are sent to agents, and how to wire the provided zsh/bash snippets
so that your terminal consults the router before executing a command.
1. Router quick reference
rlp route "<prompt>"(orrlp route --stdin) prints a stable JSON payload{ "version": 1, "route": "shell|agent", "rule": "...", "blocked": bool }. It exits10for shell decisions,11for agent decisions, and12on a timeout (--timeout-msorRUNLOOP_ROUTER_TIMEOUT_MS, default 200 ms) so shells can branch on$?without parsing stdout.rlp why "<prompt>" --json|--tableshows the matched rule, allow/deny hits, and any features (POSIX tokens, known commands, heuristics).- Configuration lives under
router.*inconfig.yaml:router.fastpath_shell: disable to always route to the default opening.router.default_opening: semantic name used in traces/telemetry.router.allowlist/router.denylist: substring matches that force shell routing or block prompts entirely.router.known_commands: extends the builtin command dictionary feeding the heuristics.
- All routing is local: there is no KB/model work in
rlp route, so it is safe to call on every prompt.
2. Shell helper (rlp shell)
The CLI manages shell snippets via rlp shell:
# inspect without changing files
rlp shell enable --shell zsh --dry-run --snippet packaging/shell/runloop.zsh \
--opening "$HOME/.runloop/openings/router-default.yaml"
# write the block into ~/.zshrc (default rc path)
rlp shell enable --shell zsh --snippet packaging/shell/runloop.zsh \
--opening "$HOME/.runloop/openings/router-default.yaml"
# remove it later
rlp shell disable --shell zsh
Key behaviors:
-
The helper looks for snippets under
/usr/share/runloop/shell(packaged installs),/usr/local/share/runloop/shell,/opt/homebrew/share/runloop/shell,~/.runloop/shell, and—as a convenience when working inside the repo—packaging/shell/. -
--snippet <path>overrides discovery; the command errors if the file does not exist (use--dry-runto preview). The helper canonicalizes snippet and opening paths before writing, so relative inputs like--snippet packaging/shell/runloop.zshremain valid once you start new shell sessions elsewhere (the block stores an absolute path). -
--opening <path>injects anexport RUNLOOP_ROUTER_OPENING_PATH=...line so the snippet knows which YAML opening to use when routing to agents. If you omit--opening, the helper copies a packaged default (from/usr/share/runloop/openings/router-default.yaml,/usr/local/share/runloop/openings/router-default.yaml, or/opt/homebrew/share/runloop/openings/router-default.yaml) into~/.runloop/openings/router-default.yamlif that file is absent, and points the env var at the user copy. Existing user copies are never overwritten; if neither path exists you can still pass--openinglater. -
All edits are wrapped by a marker block, e.g.
# >>> runloop shell (zsh) >>> export RUNLOOP_ROUTER_OPENING_PATH='/home/me/.runloop/openings/router-default.yaml' source '/usr/share/runloop/shell/runloop.zsh' # <<< runloop shell (zsh) <<< -
Default rc targets: zsh →
~/.zshrc, bash →~/.bashrc. Use--rc-pathto override;--shell autoresolves from$SHELL. -
Dry-run prints the resolved rc path, snippet/opening paths, and the exact block without writing files.
-
If the block already exists but points to a different snippet/opening,
enablereplaces it in-place (with a timestamped backup). -
If
~/.runloop/openings/is missing,enablecreates it (0755) and copies the packagedrouter-default.yamlunless creation fails, in which case it warns and continues without setting the env. -
rlp shell disableremoves the block (idempotent) and keeps timestamped backups (.zshrc.runloop.bak.<epoch>).
3. Zsh integration (packaging/shell/runloop.zsh)
The zsh snippet installs a ZLE widget runloop-accept-line that wraps the
builtin accept-line and behaves as follows:
- Guardrails: only runs in interactive shells, skips when
$TERM=dumb, whenRUNLOOP_ROUTER_DISABLE=1, when common CI/SSH envs are present (CI|GITHUB_ACTIONS|BUILDKITE|TEAMCITY_VERSION|JENKINS_URL|GITLAB_CI|CIRCLECI|SSH_CONNECTION|SSH_TTY), or whenrlpis missing from$PATH. Override withRUNLOOP_ROUTER_FORCE=1or toggle mid-session viarunloop_router_on/off. - On Enter it calls
rlp route --stdin, ignoring stdout and branching on the exit code:10→ shell fast-path: callzle .accept-lineso the command executes normally.11→ agent path: convert the buffer into JSON ({"prompt":"..."}), callrlp run <opening.yaml> --params '<json>', clear the buffer, and redraw the prompt. The default opening path resolves fromRUNLOOP_ROUTER_OPENING_PATH(injected by the helper) or the fallback~/.runloop/openings/router-default.yamlif it exists.12→ router timeout: showsrunloop: router timeout; executing in shelland falls back to the shell.- other exit codes → warn via
zle -Mand fall back to the shell.
- Helper functions:
runloop_router_off/runloop_router_onexport or unsetRUNLOOP_ROUTER_DISABLEmid-session._runloop_router_prompt_jsonhandles basic escaping (quotes, backslashes, newlines) to avoid external dependencies.
- Key binding: defaults to
^M(Enter). Override withRUNLOOP_ROUTER_BINDKEY='^J'(set before sourcing) to avoid conflicts with oh-my-zsh/Prezto keymaps; the widget binds in the main,viins, andvicmdmaps.
Requirements & tips
- Ensure
rlp runcan reachrunloopd(or pass--localvia your opening) so agent runs succeed; failures are echoed inline and the original command is not executed. - When copying additional openings for shell routing, keep them under a writable
directory (
~/.runloop/openings/or/usr/share/runloop/openings/). - Use
RUNLOOP_ROUTER_DISABLE=1 zshto start a shell with routing disabled; runrunloop_router_onlater to re-enable.
4. Bash integration (packaging/shell/runloop.bash)
The bash snippet wires a bind -x handler for Ctrl-M / Ctrl-J (Enter). The
handler inspects $READLINE_LINE, calls rlp route, and either evaluates the
line via eval (shell route) or invokes the opening (agent route). Behavior
mirrors the zsh widget with bash-specific nuances:
- Guardrails: interactive shells only; skips when
RUNLOOP_ROUTER_DISABLE=1, whenTERM=dumborPS1is empty, or when common CI/SSH envs are present (CI|GITHUB_ACTIONS|BUILDKITE|TEAMCITY_VERSION|JENKINS_URL|GITLAB_CI|CIRCLECI|SSH_CONNECTION|SSH_TTY). Override withRUNLOOP_ROUTER_FORCE=1. - Version gate: requires Bash ≥5.1. Older shells (e.g., macOS system Bash 3.2) emit a warning and skip to avoid readline/bind incompatibilities.
- Routing path: passes
RUNLOOP_ROUTER_TIMEOUT_MSthrough torlp routeand treats exit12as a timeout (warns, then executes the line via the shell). - Execution records history using the same
HISTCONTROL/HISTIGNOREsemantics as readline (leading-space secrets and ignored patterns remain out of history). - Agent runs print a newline, run
rlp run <opening.yaml> --params ..., and return to the prompt without executing the buffer. - Exit codes propagate:
$?,&&/||, andset -ebehave as if the user executed the command or opening manually. runloop_router_off/runloop_router_onmirror the zsh helpers; key binding defaults to Enter (^M) plus^JunlessRUNLOOP_ROUTER_BINDKEYoverrides with a single binding (invalid bindings log a warning and fall back to^M/^J).- Because bash lacks a native status bar, errors are printed to stderr.
- The snippet avoids non-interactive shells by examining
$-; sourcing it in scripts is a no-op.
Limitations (MVP):
- Multi-line PS2 prompts are not currently intercepted; the router only handles single-line READLINE buffers.
- If
rlp routeexits non-zero (e.g., misconfigured config), the line executes as a shell command after printing the error message.
5. Runtime toggles & troubleshooting
| Toggle / Command | Effect |
|---|---|
RUNLOOP_ROUTER_DISABLE=1 | Globally disable routing (shells fall back to normal behavior). |
RUNLOOP_ROUTER_FORCE=1 | Override CI/SSH auto-disable and force routing on. |
runloop_router_off/on | Convenience helpers provided by both snippets. |
RUNLOOP_ROUTER_OPENING_PATH | Absolute path to the YAML opening to invoke for agent routes. |
RUNLOOP_ROUTER_OPENING_PATH_DEFAULT | Optional fallback path (defaults to ~/.runloop/openings/router-default.yaml). |
RUNLOOP_ROUTER_TIMEOUT_MS | Timeout for rlp route (default 200); exit code 12 on expiry. |
RUNLOOP_ROUTER_BINDKEY | Key sequence to bind the widget (default ^M; bash also binds ^J unless set). |
RUNLOOP_ROUTER_DEBUG=1 (future) | Reserved for verbose logging (not yet implemented). |
Common issues:
snippet not found– pass--snippet <path>torlp shell enableor copy the repo snippets to~/.runloop/shell/.set RUNLOOP_ROUTER_OPENING_PATHwarning – ensure the referenced YAML exists; the helper does not create it for you.- Daemon unreachable –
rlp runwill printdaemon ... unreachable; startrunloopdor rerun withrlp run --localinside your opening. - Inspecting decisions – run
rlp why "<prompt>"after the fact to see the rule/feature breakdown. - Rolling back – run
rlp shell disable --shell <zsh|bash>to remove the block or manually delete the marker section.
6. Linking from other docs
README.mdreferences this document in the CLI/TUI section.docs/architecture.mdreplaces the “forthcoming router section” with a link here.docs/SUMMARY.mdlists it under “Tools” for mdBook navigation.- Debian postinst (rlp package) prompts the invoking user once to enable the
login shell; it skips CI/SSH/noninteractive/TERM=dumb and never edits rc files
on uninstall (use
rlp shell disableto remove the block).
With the helper and snippets in place, interactive prompts in supported shells
are classified before execution, shell-routed commands behave normally, and
agent-routed prompts flow through rlp run without manual wiring.