Files
rhema-claw/crates/rhema-claw
vhaudiquet aa9a68ab6d feat(contract): /v1/calendars joins the tool contract, one allowlist for both doors
The calendar table gets the same contract treatment as every other
data domain: a named route constant, three allowlist rows (GET, POST,
DELETE /v1/calendars/{name}), mock-router arms that enforce the
door's loudest rules — a named calendar, an ics with a URL — and
conformance cases (add, the invalid refused, listed, removed) that
run against the mock and the real daemon alike, so the two cannot
drift. The claw's auth middleware reads the same list, which is how
the rows land there with no claw-side code.

The spec names the doors and the pattern they establish: domains the
daemon reconfigures at runtime live in tables behind their routes,
validation runs at the door, and claw.yaml carries only what boot
needs.
2026-10-24 01:58:09 +02: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

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)
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 JSON, data: {..}: user_message, thinking_delta, text_delta, tool_start, tool_result, turn_done. A subscription opens with the session's recorded turns replayed first and the live feed after, cut atomically daemon-side — every streamed event is persisted as it streams, so both a reload and a daemon restart rebuild the whole transcript, not a recent window; a session that runs for weeks scrolls back to its first message. 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.

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 conversation and state: the payload lands in the phone tables, so the phone tool's status/devices actions read the latest battery, charging, network, and DND observations without asking anyone. 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.