Inbox & Channels
Two seams connect PersonalClaw to the outside conversational world: the
inbox (things arriving for the user’s attention) and channels
(bidirectional messaging surfaces like Slack — implemented entirely by apps
against core protocols). Paths are relative to
PersonalClaw/src/personalclaw/.
inbox.py— the item store.inbox_service.py— the service loop: polls sources every 60 seconds, ingests with dedup and mute/dismiss filters (muted threads are dropped at ingestion), and evaluates alerts at ingestion time for both push and poll paths (evaluate_alert→notify_inbox_alert), so an alerting item notifies immediately rather than on the next page view. Maintenance (retention cleanup) runs every 6 hours (_MAINTENANCE_EVERY_SECS).- AI drafts write on behalf of the operator (the
dashboard.user_nameidentity), not the bot. - Sources —
inbox_providers/ships native push + filesystem sources; the seam is entry-point discoverable (provider_registry.py) so apps may contribute sources, but none do today by decision: channels are channel providers, not inbox sources. - Settings live solely in
~/.personalclaw/entity_settings/inbox.json(auto_cleanup_enabled,retention_days) with type- and range-guarded PUTs inproviders/entity_routes.py. Alerting is no longer here: the formeralert_keywords/alert_on_name_mentionfields were retired and generalized into per-kind ruleconditions(below), so the same keyword / name-mention escalation now applies to loop requests and proposals, not just messages.inbox.evaluate_alert()reads theinbox/alertrule’s conditions.
Notifications
Section titled “Notifications”DashboardState.notify() (dashboard/state.py) is the single choke point
for user-facing notifications. Two layers of policy apply, in this order.
1. The global gate — notification_allowed()
(providers/entity_routes.py), unchanged and outermost:
- severity rank map with
min_severity; - midnight-wrapping quiet hours (severity-3 bypasses);
mute_all;- suppressed means dropped entirely (not queued); a gate failure fails open (a broken settings file must not silence the system).
Preferences persist in entity_settings/notifications.json with enum/HH:MM
domain-guarded PUTs.
2. The per-kind rule — notification_rules.py. Every notification is a
registered (source, kind) pair (notification_kinds.py); each pair resolves
to a rule:
- mode —
never(drop),badge(persist without a toast),immediate(deliver),digest(batch intodigest_queue.jsonlfor the scheduled summary); - targets —
dashboardtoday;channel_dmviaChannelDelivery.deliver_notification;push/nativeare accepted and persisted but inert until the mobile/desktop plans land; - conditions — keywords / name-mention that escalate a quieter mode to
immediate. Escalation is capped atimmediateand never adds targets the user didn’t choose.
Rules live in entity_settings/notification_rules.json with a guarded
PUT /api/notifications/rules; the matrix is Settings → Notifications →
Per-kind delivery. Rules refine delivery for notifications that already
passed the gate — they can never resurrect a suppressed one, so mute_all
still means mute. Every failure path (missing file, malformed JSON, unknown
mode/target) falls back to the registry default, which is immediate: a policy
layer that cannot read its own config must not be able to silence the system.
An unregistered pair resolves to system/generic with a warning rather than
raising.
Unread counts are derived from unacked log entries; deletes broadcast
notification_removed. Notification metadata may carry a channel_link —
built via ChannelDelivery.build_thread_link, never by core string-formatting
a vendor URL.
Channels: the two core seams
Section titled “Channels: the two core seams”Core owns two protocols and zero vendor code:
Inbound — channel_transports/
Section titled “Inbound — channel_transports/”base.py defines ChannelTransportProvider; manager.py is the registry.
The gateway iterates list_transports() and calls each transport’s
start_inbound(services) with the GatewayServices object
(gateway_services.py — sessions, context builder, conversation log,
consolidator, cron service, subagent manager, channel history, dashboard
state, config, owner id). Two implementations ship in-tree: webui.py (the
dashboard itself as a transport) and reference_echo.py (a minimal example).
Outbound — channel_delivery.py
Section titled “Outbound — channel_delivery.py”The ChannelDelivery protocol: open_dm, deliver_text, deliver_rich,
deliver_cron_result, deliver_notification, deliver_chat_mirror,
deliver_subagent_reply, resolve_user_name, resolve_user_profile,
channel_info, list_reply_channels, is_tracked_channel,
build_thread_link, upload_attachment, and streaming primitives
(start_stream / append_stream_task / stop_stream), plus
request_approval. Everything the gateway sends outward flows through the
registered implementation.
Vendor-blind grammar
Section titled “Vendor-blind grammar”The delivery vocabulary names no vendor anywhere in core:
- background results route via
deliver="channel[:<chan>:<ts>]"; - the
notifyMCP tool’s session enum is["origin", "channel"](mcp_core.py); /api/send-messageresponds{"ok", "channel", "session"};- Security Event Log labels use
downstream_service="channel"; - chat-history rows carry
origin="channel"(see chat-sessions.md).
The reference channel app: apps/slack-channel
Section titled “The reference channel app: apps/slack-channel”apps/slack-channel/slack_runtime/ (14 modules) is the full worked example of
a channel provider:
transport.py— implementsstart_inbound;runtime.py— a facade proxyingGatewayServices;delivery.py—SlackDelivery, theChannelDeliveryimplementation (including the vendor deep link behindbuild_thread_link);events.py/handler.py/interactions.py— inbound event routing;blocks.py/format.py/files.py— vendor message formats;allowlist.py/enterprise.py— access control;settings.py— the app-ownedSlackSettingsstore with a loud, retry-safemigrate_from_core()(all channel config lives app-side; core’s config loader defines no channel dataclasses).
Why it’s shaped this way — and which small Slack-named constants deliberately remain in core — is covered in provider-boundary.md.
Channel-thread ↔ session linking
Section titled “Channel-thread ↔ session linking”- The persistent map is core:
session_map.pyset_channel_link/get_channel_link(genericthread_ts/channel_idkeys). Channel apps go through these calls; they never touch the map file. - Dashboard-side link/handoff routes are
dashboard/chat_channel.py(POST /api/chat/sessions/{session}/channel-link,GET /api/channels/reply-targets) — provider-blind,ChannelDeliveryonly. sync_bridge.pyhands a dashboard conversation off to a channel thread (handoff_to_channel);voice_reply.pyuploads TTS voice replies (upload_voice_to_channel).channel_history.pykeeps a rolling per-channel message window (observe_max_messages/observe_ttl_hours— generic top-level config keys).
Related docs
Section titled “Related docs”- Session model & memory modes on channel threads: chat-sessions.md
- The boundary judgments behind the channel split: provider-boundary.md
- How a channel app is installed and sandboxed: app-platform.md