# clanker-saddle ClankerSaddle is an LLM task orchestration platform: a Statum + Spry web application that queues work for humans and dispatches it to [`saddle`](../saddle) daemons — one-container agents that work on real git checkouts through bash sessions with full auditability. You design tasks during the day; the agent works while you sleep. ## Architecture ``` Browser ←→ clanker-saddle (Spry app: SQLite queue, Statum UI, dispatcher) ↕ HTTP (poll-based, 5s ticks) saddle daemons (Vala: bash session + LLM loop, port 7100) ↕ curl any OpenAI-compatible chat-completions endpoint ``` The web app owns projects, tasks, dependencies and the saddle registry. Its dispatcher pairs ready tasks (dependencies done, priority first) with idle saddles, pushes each assignment to the daemon's `POST /start` (including the LLM endpoint config), then polls `GET /output` and `GET /status` every five seconds to mirror the agent's bash history into `session_commands` and to project its `saddle comment/subtask/done` events onto the task. Blocking questions flow back the same way: the daemon's question appears on the task page, and the human's answer is pushed to the daemon's `POST /answer` on the next tick. | Component | Lives | Role | |---|---|---| | Web app | `src/` | pages, actions, services, dispatcher, saddle API | | Saddle daemon | `../saddle` | bash PTY-free session, LLM loop, control server | | Mock LLM | `../saddle/test/mock_llm.py` | scripted endpoint for testing | Orchestration behaviour to know: | Setting | Meaning | |---|---| | `SADDLE_LLM_BASE_URL` | LLM endpoint sent with every assignment; unset pauses dispatch | | `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL` | Optional credentials/model for the above | | `SADDLE_PROJECTS_ROOT` | Checkouts are addressed as `root/` inside the saddle container (default `/workspace`) | ## Quick start (local) ```bash export WS_PREFIX="$HOME/.local" export PKG_CONFIG_PATH="$WS_PREFIX/lib64/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" export XDG_DATA_DIRS="$WS_PREFIX/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" export LD_LIBRARY_PATH="$WS_PREFIX/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" meson setup builddir && ninja -C builddir ``` Run the app, a saddle daemon and a mock LLM in three shells (the mock's port must be reachable from the daemon; project checkouts live under `SADDLE_PROJECTS_ROOT`): ```bash export SPRY_SEED_ADMIN_USERNAME=admin SPRY_SEED_ADMIN_PASSWORD=change-me export SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 SADDLE_LLM_MODEL=mock export SADDLE_PROJECTS_ROOT=/tmp/clanker-e2e/workspace ./builddir/clanker-saddle 8080 ../saddle/builddir/src/saddle --port 7100 python3 ../saddle/test/mock_llm.py --port 9999 ``` Browse to `http://localhost:8080`, log in with the seeded admin account, register the saddle on `/saddles` with host `localhost:7100`, and create your first project and task. `spry dev` wraps the first shell (rebuild + restart on save); `web-config.json` (from `spry keys`) keeps sessions alive across restarts and is never packaged or baked into images. The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set. ## Docker setup Two multi-stage Alpine images build everything from source against musl (see `../Dockerfile.saddle` and `../Dockerfile.clanker-saddle`): ```bash podman build -f Dockerfile.clanker-saddle -t localhost/clanker-saddle:0.1 .. podman build -f Dockerfile.saddle -t localhost/saddle:0.1 .. podman network create clanker-net podman run -d --name clanker-web --userns=keep-id --network clanker-net \ -p 8080:8080 -v "$PWD/data:/var/lib/clanker-saddle:Z" \ -e SPRY_SEED_ADMIN_USERNAME=admin -e SPRY_SEED_ADMIN_PASSWORD=change-me \ -e SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 \ -e SADDLE_PROJECTS_ROOT=/workspace \ localhost/clanker-saddle:0.1 podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \ -p 7100:7100 -v "$PWD/workspace:/workspace:Z" \ localhost/saddle:0.1 ``` Register the saddle with its **network** address (`clanker-saddle:7100`) — the web app talks to the daemon container-to-container, while the LLM base URL is dialled by the daemon itself (a mock can run inside the saddle container via `podman exec -d clanker-saddle …`, reachable at `http://127.0.0.1:9999`). Put the SQLite store on a volume (`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`) and mount git checkouts under `/workspace`. On SELinux hosts the bind mounts need `:Z`, and rootless podman needs `--userns=keep-id` for the mounts to be writable. For durable sessions mount `web-config.json` read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs with ephemeral keys. ## Creating your first project and task 1. **Project** — `/projects` → name, git URL, default branch. The checkout must exist at `$SADDLE_PROJECTS_ROOT/` on the saddle's filesystem (clone it into the workspace volume; the agent does not clone). Link related projects as *co-developed* (agent may modify) or *reference* (read-only) from the project page. 2. **Task** — `/tasks` → project, title, description, priority (1 is highest), optionally a design task (explore/plan/question only). Chain work with dependencies: a task waits until everything it depends on is done. 3. **Saddle** — `/saddles` (admin) → name plus the daemon's host:port. Within five seconds the dispatcher assigns the highest-priority ready task to an idle saddle and the agent starts working. Tasks can also arrive from the agent itself: `saddle subtask` lines create child tasks, and push-style daemons may report through `POST /api/saddle/{id}/update` (`output`, `comment`, `block`, `subtask`, `done`, `status`) and collect answers from `GET /api/saddle/{id}/poll`. ## Monitoring agent progress - **`/tasks`** — live queue: status chips (`queued → assigned → working → blocked → done/failed`), dependency states and ready flags. - **`/tasks/{id}`** — the control room; it refreshes every three seconds while a saddle works: comments thread (yours and the agent's), blocks, subtasks, session history, and the agent's final summary. - **`/tasks/{id}/terminal`** — the full bash session log: every command the agent ran with its combined output and exit code, plus its commentary lines (`# …`) and saddle events. - **`/`** — dashboard: saddle cards, queue summary, recent activity. ## Answering block questions When an agent needs a human decision it runs `saddle block ""`; the task flips to **blocked** and the question appears on its page with an answer form. Type the answer, submit — the dispatcher delivers it to the daemon within five seconds, the agent resumes with the answer in its conversation, and the task returns to `working`. Answers are delivered exactly once per question. ## Integration testing `test/integration_test.py` drives the whole loop against a running app + daemon pair: explore task, dependency gating, block + answer, and the audit trail (26 checks, Statum-style PASS/FAIL). Host run: ```bash mkdir -p /tmp/clanker-e2e/workspace && cd /tmp/clanker-e2e/workspace \ && git init demo && cd demo && git commit --allow-empty -m one \ && git commit --allow-empty -m two WEB=http://localhost:8080 SADDLE=http://localhost:7100 \ WORKSPACE=/tmp/clanker-e2e/workspace \ DB_PATH=/tmp/clanker-e2e/web/clanker-saddle.sqlite \ python3 test/integration_test.py ``` For a container run point `WEB`/`SADDLE` at the published ports, pass `SADDLE_HOST=clanker-saddle:7100`, the container's SQLite file as `DB_PATH`, and start the in-container mock via `MOCK_CMD='podman exec -d clanker-saddle python3 /usr/libexec/saddle/mock_llm.py …'`. ## Deployment (USM) `MANIFEST.usm` + `usm-scripts/` package the app as `bin:clanker-saddle` (`usm manifest package`), published to the Web-Stack repository: ```bash usm manifest package cd ../web-stack-repo && usm repository add ../clanker-saddle-0.1.usmc ``` `web-config.json` is never packaged (`.usmignore`); keys are mounted at runtime, never baked into images.