Agents / Hosted agents
Hosted agents
A hosted agent is a headless agent that lwisd runs for you on demand: a receipts filer, a triage bot, a script. You send it a capture from your phone like to any session, and its answer comes back as a reply.
How a hosted agent runs
Each agent is a TOML profile in ~/.config/lwis/agents/<name>.toml (on macOS, ~/Library/Application Support/lwis/agents/). lwisd loads the profiles at start; after editing one, run lwis agents reload.
- The agent appears on your devices as a target, with a session id that stays the same across restarts.
- A capture sent to it starts the agent. It stays warm for
idle_minutes(default 5), then lwisd stops it. - Its final answer goes back to the device that sent the capture as a plain-text reply (at most 1000 characters). If it ends without an answer, you get "
<name>finished without a reply"; if it fails, "<name>could not answer: …". lwis pausealso stops new captures reaching hosted agents (a turn already running finishes).
The full list of keys and rules is in Agent profile format.
Runtimes
| Runtime | What lwisd runs |
|---|---|
claude-sdk | claude -p in stream-json mode, with --restricted --safe-mode (no user or project settings, plugins, hooks, skills or MCP servers), your permission_mode, and only the tools you list. lwisd answers every permission prompt itself. |
command | Any program, started in cwd (a leading ~/ is expanded), that speaks the lwis agent contract as JSON lines on stdin and stdout. It gets LWIS_AGENT=<name> in its environment. |
webhook | One HTTP POST of the same contract message to a localhost URL, with the image bytes inlined as base64. |
codex-exec is recognised but refused: it isn't implemented yet.
The agent contract
For command and webhook, lwisd sends one JSON object per capture:
{"type": "capture", "cid": "c_…", "agent": "receipts", "strict": true,
"from": {"id": "…", "name": "Pixel", "self": true},
"intent": "…", "note": "…", "gestures_text": "…", "untrusted": "<untrusted …>…</untrusted>",
"text": "…the same prompt a claude-sdk agent gets…",
"parts": [{"role": "annotated", "idx": 0, "mime": "image/png", "size": 123456, "path": "/run/user/…/…png"}]}
For a contact's capture (forwarded, or sent directly to an agent you allowed), from names the contact with your petname and their fingerprint, self is false, intent and note are null (their text moves inside untrusted, as data), strict is always true, and when you forwarded it, via is "self" and owner_note carries your own request (otherwise both are null).
A webhook gets data (base64) instead of path in each part. The agent answers with zero or more {"type": "reply", "cid": "c_…", "text": "…"} lines, then {"type": "done", "cid": "c_…"}. A webhook may instead return a JSON array of those objects, a single one, or {"text": "…"}. Lines for another capture id, or that don't parse, are ignored. A turn that doesn't finish within turn_timeout_seconds (default 300) fails.
Test a contract program without lwisd:
lwis agents run --image receipt.jpg --note "file this" -- ./my-agent.py
Manage agents
lwis agents list # loaded profiles, refused files, pending approvals (--json)
lwis agents check receipts.toml # validate a file; for claude-sdk, print the exact claude command
lwis agents reload # re-read the profiles
A profile that fails validation is refused, and lwis agents list shows why. Profiles and their directory must be owned by you and not writable by group or others.
Strict approvals
Profiles are strict by default (strict = true). In a strict claude-sdk agent, read-only tools (Read, Glob, Grep, LS, TodoWrite, NotebookRead) run straight away, and every other tool call waits for you:
-
lwisd shows a desktop notification ("lwis: receipts wants to Write inbox/2026-09.csv") and sends the same notice to your phone as a plain-text reply.
-
Review it:
lwis agents list -
Approve or deny it by its approval id:
lwis agents approve a_… lwis agents deny a_…
An action nobody approves is denied after approval_timeout_minutes (default 10). Approval ids are random and never shown to the agent; lwis accept <cid> never approves an agent action. A tool granted only with a specifier, such as Bash(git status), also waits for you, even when the agent isn't strict: lwisd doesn't evaluate the specifier.
Approving from the phone isn't available yet: you approve on the computer.
Who can reach an agent
accept_from lists who may send captures to the agent. The default is ["self"]: your own paired devices, and captures you forward.
A contact can reach an agent directly only when both hold:
- the profile's
accept_fromnames them (prefer their identity id or fingerprint to their petname: a petname can later name someone else); - you turned their agents policy on:
lwis contacts policy <contact> --agents on(off by default).
Otherwise their capture stays in your inbox, and you decide whether to forward it.
Another machine's agent reaches a hosted agent through an agent pairing: name it as "agent:<label>" in accept_from. Its reports also run strict, in a fresh process, and its answer goes back to that agent.
Whatever the profile says, a contact's capture always runs strict, in a fresh one-shot process that never sees your earlier conversation with the agent. A permission mode other than default or plan is lowered to default for it.
Forward a contact's capture
Captures from other people land in lwis inbox, never directly in a session or agent. To hand one to an agent or a Claude session:
lwis forward c_… --to receipts --note "file this receipt"
lwis forwards # what you forwarded, and the replies
--totakes a hosted agent's name, or a session id fromlwis sessions. Forwarding to an agent needs"self"in itsaccept_from.- Your
--noteis the only request. The sender is named in the fence as "(not you)" with your petname for them and their fingerprint; their own note and intent are passed as data. - Replies come to you: your paired devices and
lwis forwards. With--reply-to-sender, they also go to the original sender, only while their replies rule allows it (an active contact, a capture received in the last 7 days).