SysKnife User Stories — End-to-End Code Trace
Ten user stories from the perspective of sysadmins, developers, and Linux enthusiasts. Each story traces the request through every layer of the stack: shell UI → Tauri backend → brain (LLM) → daemon → system.
Read this as one path through the stack, not the maintained one. These traces enter through the Tauri GUI, whose development is paused. The layers below the entry point — brain, daemon, executor, audit chain — are identical whichever surface you come in through, so the traces stay accurate for the CLI and MCP paths from the planner downwards. Only the first hop differs.
Code references name a file and a function rather than a line number. They used to cite
file.rs:NNN, and after a refactor every one of them pointed at unrelated code — a line number is a claim that rots silently on any edit above it.
Story 1: Sysadmin layers vim on Silverblue
Persona: Sysadmin managing a fleet of Silverblue workstations.
Intent: "Install vim as a layered package"
E2E trace:
-
IntentPane (
apps/sysknife-shell/src/components/IntentPane.tsx) User types intent, clicks Submit → dispatchesintent_submitted. -
daemonBridge (
apps/sysknife-shell/src/daemonBridge.ts:10)requestPlan(intent)→ Tauri IPCinvoke("plan_intent", { intent }). -
Tauri command (
apps/sysknife-shell/src-tauri/src/commands.rs)plan_intent()→execute_plan_intent()callsstate.planner.plan_intent(intent). -
LLM planning loop (
crates/sysknife-brain/src/planner.rs—plan_intent()) Turn 0: LLM receives system prompt (192-action catalogue, risk rules) + user message. LLM callsget_system_state. -
State injection (
crates/sysknife-brain/src/planner.rs—plan_intent())state_client.curated_state()→ daemon IPC →collect_state()(crates/sysknife-daemon/src/state_collector.rs—collect_state()) runshostname,rpm-ostree status(once, for both the deployment and the layered packages),systemctl list-units,flatpak list,toolbox list,podman psandgetent passwd. Returns JSON with host, deployment, services, flatpaks, toolboxes, layered packages, containers and users. -
LLM proposes plan — calls
propose_planwith:{"summary": "Layer vim via rpm-ostree", "explanation": "This layers the vim package...", "steps": [{"action_name": "InstallPackages", "summary": "Layer vim", "risk_level": "high", "params": {"packages": ["vim"]}}]} -
Safety fence (
crates/sysknife-brain/src/planning_tools/propose_plan.rs)ActionName::parse("InstallPackages")→ OK (in KNOWN_ACTIONS). Risk "high" →PlanRiskLevel::High.PlanStep::new()succeeds. -
Plan returned to shell as
PlanResponse(commands.rsplan_to_response()). -
PlanPane (
apps/sysknife-shell/src/components/PlanPane.tsx) Shows step "InstallPackages" with red HIGH badge. Aggregate risk = high → user must type "InstallPackages" to confirm. -
Approval →
requestApproval(steps)→ Tauriapprove_preview. -
Daemon preview (
crates/sysknife-daemon/src/dispatcher.rs—handle_preview())handle_preview("InstallPackages", params)→ callspreview_action()→ returns risk=High, rollback_available=true, side_effects, request_hash. -
Daemon execute (
crates/sysknife-daemon/src/dispatcher.rs—handle_execute()) Validates approval_hash == request_hash. Callsbuild_action_spec("InstallPackages", params)(crates/sysknife-daemon/src/executor.rs—build_action_spec()) → buildsrpm-ostree install vim. Executes viaexecute_spec(). Streams stdout lines asJobProgressframes. -
Live output → shell emits
sysknife:timeline-entryevents → ExecutionPane shows live log. -
Completion →
sysknife:job-completedwithsucceededorneeds_reboot. Transaction persisted to SQLite.
Gap identified: The LLM sees Resolved: the curated state now
carries flatpaks and services but not
the list of already-layered packages.layered_packages, and the planner can also call
query_packages before proposing AddLayeredPackage.
Story 2: Developer rebases to Fedora 42
Persona: Developer on Silverblue who wants to upgrade.
Intent: "Rebase this system to Fedora 42"
E2E trace:
-
Intent →
plan_intent→ LLM callsget_system_state, seesdeployment: "fedora/41". -
LLM proposes:
{"steps": [ {"action_name": "RebaseSystem", "risk_level": "high", "params": {"target_ref": "fedora:fedora/42/x86_64/silverblue"}}, {"action_name": "RebootSystem", "risk_level": "high", "params": {}} ]} -
Safety fence: both actions in KNOWN_ACTIONS, both high risk. Valid.
-
PlanPane: two steps, both HIGH. User types "RebaseSystem" to approve.
-
Daemon executes
rpm-ostree rebase fedora:fedora/42/x86_64/silverblue. Live output streams download progress. If rebase fails,rollback_spec_for("RebaseSystem")triggersrpm-ostree rollbackautomatically (crates/sysknife-daemon/src/executor.rsrollback path). -
Job completes with
needs_reboot. ExecutionPane shows reboot banner.
Gap identified: The LLM hardcodes the ostree ref string in
params. There is no tool to query available refs or validate that
fedora/42 exists before proposing the rebase. A
ListAvailableRefs action would prevent plans that fail at
execution.
Story 3: Sysadmin checks what is running
Persona: On-call sysadmin investigating a slow system.
Intent: "Show me all running services and installed flatpaks"
E2E trace:
-
LLM receives intent. Calls
get_system_state— gets services and flatpaks from CuratedState. But this is a summary, not the full list. -
LLM proposes:
{"steps": [ {"action_name": "ListServices", "risk_level": "low", "params": {}}, {"action_name": "SearchFlatpakApps", "risk_level": "low", "params": {"term": "firefox"}} ]} -
Both low risk →
approval_required = false. Plan auto-executes (no gate). -
Daemon runs
systemctl list-units --type=service --state=runningandflatpak list. Output streams to timeline. -
User sees full output in ExecutionPane live log.
Gap identified: Low-risk read-only plans still go through the full approval pipeline even though no mutation occurs. The UX could short-circuit to show results inline without the execution pane ceremony. Also, the LLM does not receive the output of these commands — it only proposes them. The user sees the output, but the LLM cannot reason about what it found (no feedback loop for read-only queries).
Story 4: Developer sets up a toolbox for Rust development
Persona: Developer who wants an isolated dev environment.
Intent: "Create a new toolbox called rust-dev and enter it"
E2E trace:
-
LLM proposes:
{"steps": [ {"action_name": "CreateToolbox", "risk_level": "medium", "params": {"name": "rust-dev"}} ]}(
EnterToolboxwas removed — the LLM will not propose it.) -
Medium risk. PlanPane shows an orange MEDIUM badge. User checks "I understand this will modify system state" and clicks Approve.
-
Daemon runs
toolbox create rust-dev. The plan completes after creation. -
EnterToolboxwas removed from the action catalogue because entering a toolbox is an interactive TTY operation that the daemon cannot perform on behalf of the user. The LLM will not propose this step.
Note: EnterToolbox was removed (not deferred) after review
determined that the daemon cannot meaningfully execute an interactive
shell session.
Story 5: Sysadmin hardens firewall rules
Persona: Security-conscious sysadmin.
Intent: "Allow SSH and block everything else on the firewall"
E2E trace:
-
LLM calls
get_system_state, sees current services and firewall state (viaGetFirewallStateif it proposes that first). -
LLM proposes:
{"steps": [ {"action_name": "GetFirewallState", "risk_level": "low", "params": {}}, {"action_name": "ConfigureFirewall", "risk_level": "high", "params": {"zone": "public", "service": "ssh", "enabled": true}} ]} -
Problem:
ConfigureFirewallenables or disables one service per step. "Block everything else" requires knowledge of what is currently allowed, then removing each service. The LLM cannot see the output of step 1 (GetFirewallState) before proposing step 2 — all steps are proposed in a singlepropose_plancall.
Gap identified: The planning model is single-shot: the LLM proposes all steps at once without seeing intermediate execution results. For iterative workflows ("check state, then decide what to change"), the LLM would need a multi-round planning capability where step outputs feed back into subsequent planning.
Story 6: Linux enthusiast explores the system
Persona: Curious Fedora user who just installed SysKnife.
Intent: "What containers are running and what flatpaks do I have?"
E2E trace:
-
LLM proposes two low-risk read-only steps:
ListContainers+SearchFlatpakApps(orListFlatpakRemotes). -
No approval required. Daemon runs
podman psandflatpak list. -
User sees container and flatpak listing in the timeline.
What works well: The zero-friction path for read-only queries is good. Low risk = no checkbox, no typing. The user just asks and gets answers.
What could be better: The output appears as raw command stdout in the timeline. The LLM could summarize or format the output if it had access to it after execution. Currently it is a "fire and forget" model — the LLM never sees what the commands produced.
Story 7: Sysadmin creates a service account
Persona: Sysadmin provisioning a new CI runner.
Intent: "Create a user called ci-runner and add it to the docker group"
E2E trace:
-
LLM proposes:
{"steps": [ {"action_name": "CreateUser", "risk_level": "high", "params": {"username": "ci-runner"}}, {"action_name": "AddUserToGroup", "risk_level": "high", "params": {"username": "ci-runner", "group": "docker"}} ]} -
CreateUser = high, AddUserToGroup = high. Aggregate = high. User re-types each high-risk action name to confirm.
-
Daemon input validation (
crates/sysknife-daemon/src/actions/validate.rs) checksvalidated_username("ci-runner")andvalidated_group("docker")— rejects shell metacharacters. -
Daemon runs
sudo /usr/lib/sysknife/action-steps user-create ci-runnerthensudo /usr/lib/sysknife/action-steps group-add ci-runner docker. The helper re-validates every token and builds theuseraddandusermodargv itself, because a sudoers grant cannot forbid the options that follow a wildcard. -
Transaction logged to SQLite with both action names, params, caller role and principal, and timestamps.
What works well: The role-based authorization
(crates/sysknife-daemon/src/policy.rs — action_allowed()) ensures the caller has
Admin rights for AddUserToGroup. The input validation prevents
injection (e.g., ci-runner; rm -rf /).
Story 8: Developer rolls back a failed update
Persona: Developer whose system broke after an update.
Intent: "Roll back to the previous deployment"
E2E trace:
-
LLM calls
get_system_state, sees current deployment. -
LLM proposes:
{"steps": [ {"action_name": "RollbackDeployment", "risk_level": "high", "params": {}}, {"action_name": "RebootSystem", "risk_level": "high", "params": {}} ]} -
User types "RollbackDeployment" to confirm.
-
Daemon runs
rpm-ostree rollback. If this succeeds, proceeds toRebootSystem(systemctl reboot). -
If
RollbackDeploymentfails, nothing rolls it back:rollback_spec_for(executor.rs) deliberately returnsNoneforRollbackDeployment, to prevent rollback recursion. The job endsFailed.
What works well: RollbackDeployment is itself the recovery
action, so it never chains an automatic rollback of its own.
Story 9: Sysadmin pins the current deployment before experimenting
Persona: Cautious sysadmin about to try something risky.
Intent: "Pin the current deployment so I can experiment safely"
E2E trace:
-
LLM calls
get_system_state, sees deployment info. -
LLM proposes:
{"steps": [ {"action_name": "PinDeployment", "risk_level": "high", "params": {"index": 0}} ]} -
index: 0refers to the booted deployment. Daemon runsrpm-ostree pin 0. -
User sees confirmation in timeline. Deployment is now pinned and cannot be garbage-collected.
Gap identified: The LLM has to guess that index: 0 is the
current deployment. The CuratedState includes deployment info
but not the index. A more robust approach would be for the LLM to
first call ListDeployments and use the output — but again, the
single-shot planning model prevents this.
Story 10: First-time user with no LLM configured
Persona: Curious developer who just cloned and built SysKnife.
Intent: (none yet — first launch)
E2E trace:
-
App mount (
apps/sysknife-shell/src/App.tsx)checkSetupStatus()→ Tauri command →commands.rsconfig_path_exists()checks~/.config/sysknife/config.toml,provider_is_configured()checks env vars and config. -
If neither is set →
needsSetup = true→ SetupWizard (apps/sysknife-shell/src/components/SetupWizard.tsx) renders. -
Step 1: User picks Ollama (recommended, no API key) or Anthropic.
-
Step 2: Wizard shows config.toml content to copy. For Ollama: reminds the user to pull the chosen model.
-
Step 3: "Restart the shell to apply."
-
After restart,
BrainConfig::from_env()(crates/sysknife-brain/src/config.rs) auto-detects Ollama (noANTHROPIC_API_KEY→ fallback to Ollama provider). -
LlmPlanner::from_config()(crates/sysknife-brain/src/planner.rs) builds the Ollama provider (throughRigCompletionAdapter) againsthttp://localhost:11434, with the configured model or, when none is set,qwen3:8b(DEFAULT_OLLAMA_MODEL). -
User can now type intents. Header shows the provider and model, for example "via ollama/qwen3:8b".
What works well: Zero-API-key path with Ollama is a strong onboarding story for privacy-conscious sysadmins.
Gap identified: If Ollama is not running, the error is
llm_http_error with a connection-refused message. The wizard
does not verify that Ollama is actually reachable before marking
setup as complete.
Summary of Gaps Found
| # | Gap | Impact | Severity |
|---|---|---|---|
| 1 | Resolved: layered_packages is in the curated state | — | |
| 2 | No tool to query available ostree refs | Rebase plans can reference non-existent refs | Medium |
| 3 | Execution output never reaches the LLM | Read-only query_* tools feed results back during planning, but a plan cannot react to what its own steps print | Medium |
| 4 | Resolved: action removed from catalogue | — | |
| 5 | Single-shot planning — all steps proposed at once | Cannot adapt plan based on intermediate results | High |
| 6 | Raw stdout in timeline, no LLM summarization | Read-only queries produce unformatted output | Low |
| 7 | SetupWizard does not verify Ollama reachability | User completes setup but gets connection errors | Low |
| 8 | Resolved: the prompt dispatches per distro (ADR 0004) | — |