Files
rhema-claw/crates/rhema-claw
kosmos 8db1e2aa11 feat(claw): delete a session and its turn history over http
A session the human is done with has to be removable, and removing it
must mean forgetting it: `DELETE /v1/sessions/{key}` drops the dispatch
record and the turn history recorded under the key, so re-creating the
same name starts an empty chat instead of replaying someone else's
turns. The route refuses a leased key, because the running turn's own
save would either fail against the missing record or write the session
back after the delete; the inventory is not narrowed to the
`interactive:` prefix, so a forge key deletes on the same route.

The history purge rides the hub, which owns the turn table, and it
lands after the record does: the purge is the one step this side cannot
undo, so it never runs for a delete the dispatcher refused. Purging on
the no-record path as well is what makes a half-done delete repairable
— a retried DELETE finishes the purge instead of 404-ing with the old
turns still under the key, where a session created again under the same
name would replay them.

The sessions routes stay off the agent token's allowlist in
tools/contract: the agent must not be able to delete the conversation
it is serving.
2026-11-11 16:11:07 +00:00
..
2026-11-10 19:44:04 +01:00

Rhema Claw

Still Work-In-Progress.

Rhema Claw is an autonomous personal agent ("claw"), built on the Rhema agent framework.

Features

The claw: rhema-claw

The resident agent. One daemon owns every session in one SQLite store: yours (interactive:<name>, keyed per chat surface) and every watched forge thread's. Talk to it over HTTP; it watches repos, answers forge threads with coding-agent turns, and configures its own watches when asked in chat (watch_repo/unwatch_repo/list_watches are tools the model calls like any other).

rhema-claw                     # serve on 127.0.0.1:7799 (config: listen)
rhema-claw --once              # one detect + one dispatch pass, then exit
claw mcp servers               # the MCP meta surface (see rhema-mcp)

Coding jobs: the coding_agent tool

The claw is not the only agent it runs. The coding_agent tool drives a full rhema-coding-agent session — its own shell, skills, and step budget — in a named workspace, with one tool and an action argument:

  • add pins a name to an existing directory (rhema → ~/rhema). The instance list is the audit surface for which checkouts the model can drive a coding agent in; drop removes one, refused while a job is live.
  • run enqueues a job. Async by design: a coding run is minutes of tool rounds, so the call answers with the job id in one model round and the turn runs off the chat — its own dispatcher, job: keys with forge-style leases, the same commit-before-execute discipline. The instance's coding session carries to the next run unless fresh; the final text lands in the job row and the user's notifications.
  • list names the instances, each with its live job at a glance; status reads jobs — every recent one, one instance's, or one job's result.
  • One live job per instance: two coding agents in one workspace is two agents editing the same files. Across instances, jobs run in parallel.

The bounded request view

A claw session never ends: the interactive chat is one conversation, forge threads live as long as their pull requests, and a coding job's session carries to its next run. From the start, the claw therefore runs the transcript's bounded request view (rhema-transcript): past a byte budget, older messages summarize into id+n|text lines the agent's zoom tool opens to read any original back. The config turns it off with transcript: false.

For the debug loop, daemon plus UI against throwaway state and zero credentials: scripts/dev.sh --mock (canned streamed replies; the UI is at localhost:3000, the claw at 127.0.0.1:7799). Drop --mock and set the provider's key env var (CLAW_DEV_PROVIDER picks the provider) to debug against a real model.

# ~/.config/rhema/claw.yaml
provider: anthropic
model: claude-sonnet-4-5
listen: 127.0.0.1:7799
token: change-me                 # optional; when set, /v1/* requires it
agent_token: change-me-too       # the client tools' scoped bearer (rhema-tools)
tools_bin: ~/.local/share/rhema-tools/bin  # put the client tools on PATH
poll_interval_secs: 120
max_turn_steps: 2048             # tool rounds one chat turn may run
watches:                         # a seed: imported while the table is empty
  - forge: github
    repo: acme/widgets
calendars:                       # feeds the claw reminds about
  - name: home                   # the reminder session is calendar:home
    kind: ics                    # an ics/webcal feed (Google's secret iCal
    url: https://cloud.example.net/remote.php/dav/calendars/personal.ics
    lead_minutes: [60, 15]       # default [15]; horizon_hours default 24
  - name: cloud
    kind: caldav                 # a CalDAV collection (REPORT; Nextcloud,
    url: https://cloud.example.net/remote.php/dav/calendars/me/personal/
    username: me                 # Radicale); password falls back to
    password: app-pass           # CALDAV_PASSWORD; token: = bearer instead
  - name: work
    kind: google                 # the Google Calendar API
    calendar_id: primary
    api_key: pub-key             # or token: (OAuth; env fallback CALENDAR_TOKEN)

max_turn_steps caps one turn's tool rounds (the coding driver's own budget shape, and the same default, 2048): the claw is meant to work unattended for a long time, so a cap this deep is a runaway guard, not a work timer — a turn that hits it ends with a failed outcome and the conversation intact; a follow-up message keeps going.

The surface refuses to start on a non-loopback listen with no token: anything beyond loopback can reach the port, and the bearer token is what stands between it and the agent's tools. Set a token, or bind a loopback address. Resident sessions run the coding agent with its approval seam auto-approved: tool calls, the shell included, execute without a prompt. The daemon says so at startup, and the bearer-gated surface plus the watch list are the trust boundary.

agent_token provisions the client tools: a second bearer the daemon hands the agent's shell as RHEMA_TOKEN (with RHEMA_URL), valid only for the data routes the tool contract allowlists — watches, notifications, tasks — and only with the contract header at the matching version. It cannot reach consents, sessions, ingest, or the phones, so a model holding the token cannot prompt-inject its way past the scoping (see specs/rhema-tools.md; the daemon runs the contract's conformance suite against its own router at test time). It must differ from token — the daemon refuses to start when the two are the same. tools_bin layers a directory onto the shell PATH so the provisioned tools resolve. With no agent_token, the daemon provisions no tool environment: the agent's shell is exactly as it was.

The native surface:

GET  /healthz                          GET  /v1/sessions
POST /v1/sessions                    {"name"} — create an empty interactive session
POST /v1/sessions/{key}/messages       {"text": "..."}   -> 202
GET  /v1/sessions/{key}/events         SSE turn events (see below); ?since=<seq> resumes a cursor
GET  /v1/sessions/{key}/history        one page of older turns: ?before=<seq>&limit=<n>
DELETE /v1/sessions/{key}              drop a session and its turn history
GET  /v1/watches                       POST /v1/watches  DELETE /v1/watches/{forge}/{repo}
GET  /v1/bootstrap                     {"soul": bool, "user": bool}
POST /v1/ingest                         {"source","key","payload"} -> 202
GET  /v1/notifications?device=&since=   POST /v1/notifications/{id}/ack
GET  /v1/consents                      the asks a turn is waiting on
POST /v1/consents/{id}                 {"secret"} or {"grant": bool}
DELETE /v1/consents/{id}               deny a pending ask
GET  /v1/attachments/{id}              an attached file's bytes
GET  /v1/phone/commands?device=        the device's queued, unconfirmed commands
POST /v1/phone/commands/{id}/result    {"ok", "detail"?} — the device's report
GET  /v1/providers                      the backend instances + the active pair
PUT  /v1/providers/active               {"provider","model"?} — switch, persisted
PUT  /v1/providers/{id}/credentials     add or replace an instance, persisted
DELETE /v1/providers/{id}               remove one (the last is refused)
GET  /v1/providers/{id}/models          the live model listing

Identity and first-run

The claw's prompt is assembled per turn from an in-binary base plus three identity files under the state dir's bootstrap/ (config: bootstrap_root): SOUL.md, who the claw is, tone and purpose, written with you; USER.md, the always-visible summary of who you are; ENVIRONMENT.md, host, shell, and tool inventory, generated deterministically at every boot (detection is code, not conversation; hand edits are overwritten). The base renders the dispatch's own tool roster too — every tool's name, description, and call schema, straight from the resolver chain, so what the prompt lists is what the request advertises. On first run the claw offers a short setup in chat: who you are, what the machine is for, who it should be. It writes the first two files itself through the bootstrap_save tool as you answer, and — through the identity_set tool — sets its own presentation the same way: its name, a mark or tagline, an avatar from an image you hand it. The same tool is yours to invoke any time ("call yourself something else"); it is the same store the settings pages write, so what it sets is what every client renders. Decline and it simply keeps offering, never interrogating. Both files stay hand-editable forever; /v1/bootstrap lets a UI badge setup state.

SSE frames are id: <seq> plus data: {..}: user_message, thinking_delta, text_delta, tool_start, tool_result, turn_done. Every streamed event is persisted as it streams, so both a reload and a daemon restart rebuild the transcript, and a session that runs for weeks keeps all of it.

The seq is the record's dense per-key number for the event, and it is the client's cursor. A subscription opens with a replay cut atomically against the live feed, and the frames of both carry their seqs, so a client that reconnects sends ?since=<last seq it saw> and receives exactly the gap. With no cursor the replay is the tail — the newest 400 events, cut back to the first turn boundary in them — so a weeks-old session paints its latest turn at once instead of replaying everything; the turns above it come from the history route, one whole turn per page, each ending exactly where the previous page began. A cursor the record can no longer serve (a wiped table) answers with a reset; comment and the tail, and the client rebuilds rather than splice.

A chat UI subscribes once, POSTs messages, and resets its mirror whenever the stream (re)opens, letting the replay rebuild it; the daemon owns the conversation, so the client mirrors the stream rather than managing history. The OpenAI shim and the message POST read the live feed only — replay never reaches a turn that is starting.

A message into a session whose turn is running steers it, the same interrupt rhema-code's UI owns: the running turn is cut at its current await (an in-flight shell command dies with it), closes on the stream with turn_done carrying outcome: "steered", and the message runs as the next turn of the same conversation. The messages route answers 202 the moment the message is accepted — the turn runs off the request, and its reply arrives on the event stream — so a composer never locks for the length of a turn and a follow-up is always typeable.

Every provider whose credentials resolve (env, the claw's own stored logins, or the stored rhema config) is constructed at boot as a backend instance named by an id, the kind unless a login named it, so two llama.cpp servers (or two keys of one cloud) coexist under two names. One is active, and switching with PUT /v1/providers/active is runtime state persisted in SQLite: the config's provider/model pair is only the seed, and when the config names no provider the first credential discovery actually finds (a stored login, an env key, or the stored rhema config) is active; the keyless llama.cpp fallback never wins a seed, so the chart pins nothing and a switch never needs a restart.

The claw registers RSD sources four ways: watches (forge), calendar reminders (calendars: in the config; each tick polls every feed for the occurrences starting within its horizon, and an upcoming event warns once per lead_minutes on its persistent calendar:<name> session, a turn whose final text auto-notifies the phone; recurring series arrive expanded, overrides and cancellations applied, all-day events stay silent), scheduled tasks the agent creates in chat (schedule_task with at or every_secs; a due task dispatches through the resident agent — into the session the tool named, its own task:<id> slot by default — and a reply on a chat-less key auto-notifies the phone; a task whose session names a chat conversation wakes that session with its full context, so "pick this back up at 10 am" or "before my meeting, summarize X" is one tool call, with notify reaching the user away from the chat), and a generic door, POST /v1/ingest, that any external emitter addresses (phone:<device>:<topic> keys from the Android app, for example). The resident agent owns interactive:, task:, phone:, and calendar: keys; forge threads own the rest. Its notify tool queues a notification for a device; the device polls and acks.

When a turn needs something only a human can give, it asks and waits inside the tool call: ask_secret raises a consent request — a card in the chat stream (consent_request over SSE, plus GET /v1/consents for a UI catching up after a reload) — and the human answers through POST /v1/consents/{id} ({"secret": ...}) or denies with DELETE. The secret travels once, from the answer into the agent accounts; the tool wakes with the account id, never the secret, and a denial or a 15-minute silence comes back in-band so the turn can say so and move on. A watch with no token of its own polls — and replies — as the matching saved account: the same forge, and on a self-hosted forge the same base URL. Acting with a stored account is identity_exec: it runs one shell command in the session workspace with the secret injected — ACCOUNT_SECRET plus the service's own GITHUB_TOKEN/GITEA_TOKEN/ GITLAB_TOKEN, and a git credential helper — so git clone and API calls work while the credential itself never reaches the transcript. A turn can also hand files over: attach_file copies a workspace file (25 MiB cap, workspace-relative paths only) into the attachment store beside the database and puts an attachment card on the stream; the bytes are fetchable at GET /v1/attachments/{id} with the content type the file's name implies, so a screenshot, a diff, or a report reaches the chat as something to view or download rather than a truncated snippet.

The phone is reachable too. Its status reports (/v1/ingest, source phone, keys phone:<device>:<topic>) are state, not conversation: the payload lands in the phone tables and stops there — no turn runs, no push notification goes out per flip — so the phone tool's status/devices actions read the latest battery, charging, network, and DND observations without asking anyone. A bare phone:<device> key is the device's resident dispatch key: a message posted to it runs a turn like any resident key's. Acting on the phone — dnd_on, dnd_off, alert — is consent-gated end to end: the tool raises a consent card, the human approves or denies, and only then does the command join GET /v1/phone/commands?device=, the poll list the device executes from and reports on via POST /v1/phone/commands/{id}/result. The device confirms on its own clock (the 15-minute WorkManager floor), so an answer may honestly be "queued, not yet confirmed".

Stock OpenAI-speaking UIs point at the shim instead:

curl http://127.0.0.1:7799/v1/chat/completions -H 'Content-Type: application/json' -d '{
  "model": "claw", "user": "web", "stream": true,
  "messages": [{"role": "user", "content": "what are you watching?"}]
}'

The shim reads only the last user message (user picks the session, default web), maps thinking deltas to reasoning_content, and streams standard chat-completions chunks. There is one notion of session, the daemon's, behind a wire format every web UI already speaks.

Deploying the claw

docker build -f deploy/docker/claw.Dockerfile -t rhema-claw .
helm install claw deploy/helm/rhema-claw \
  --set token=$(openssl rand -hex 24) \
  --set secret.stringData.ANTHROPIC_API_KEY=... \
  --set secret.stringData.GITHUB_TOKEN=... \
  --set config.provider=anthropic --set config.model=claude-sonnet-4-5

Expose it with the chart's optional ingress (ingress.enabled + ingress.host, TLS strongly recommended, bearer tokens cross it) or keep it cluster-private behind Tailscale; the claw is bearer-gated either way. With the chart's UI on (ui.enabled, the default), the same host serves both: the browser gets the web UI on /, the daemon answers /v1 and /healthz; one address for the browser and the phone app, no CORS anywhere.

The image is the daemon plus bash and git (the agent's shell is load-bearing); the chart runs exactly one replica, the claw is single-node by design: one SQLite store, one dispatcher. Single-node is what makes restarts instant: at boot the daemon releases every lease the previous process left — outright, not at the dead lease's TTL — so a turn an update cut mid-flight answers its next message instead of stalling behind a ghost (the committed tool call revives at the top of that next dispatch). The store and workspaces live on a PVC, claw.yaml in a ConfigMap (db and workspace paths are pinned to the mounted volume), credentials in a Secret, and the bearer token is the token value, required since the daemon binds all interfaces in-cluster. persistence.enabled=false trades restart survival for disposability.

The UI in web/claw is one client of the same surface: npm run dev proxies to a local claw, npm run build emits the static bundle an ingress serves next to /v1. The phone is another. android/ is the Kotlin/Compose client: chat over the same SSE stream, phone status (battery, charging, network, DND transitions) posted to /v1/ingest as phone:<device>:<topic> events, and agent notifications polled onto the lock screen (no push infrastructure; a 15-minute WorkManager floor, live SSE while the app is open). See android/README.md.