Skip to content

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_alertnotify_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_name identity), not the bot.
  • Sourcesinbox_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 in providers/entity_routes.py. Alerting is no longer here: the former alert_keywords/alert_on_name_mention fields were retired and generalized into per-kind rule conditions (below), so the same keyword / name-mention escalation now applies to loop requests and proposals, not just messages. inbox.evaluate_alert() reads the inbox/alert rule’s conditions.

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 gatenotification_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 rulenotification_rules.py. Every notification is a registered (source, kind) pair (notification_kinds.py); each pair resolves to a rule:

  • modenever (drop), badge (persist without a toast), immediate (deliver), digest (batch into digest_queue.jsonl for the scheduled summary);
  • targetsdashboard today; channel_dm via ChannelDelivery.deliver_notification; push/native are 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 at immediate and 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.

Core owns two protocols and zero vendor code:

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).

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.

The delivery vocabulary names no vendor anywhere in core:

  • background results route via deliver="channel[:<chan>:<ts>]";
  • the notify MCP tool’s session enum is ["origin", "channel"] (mcp_core.py);
  • /api/send-message responds {"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 — implements start_inbound;
  • runtime.py — a facade proxying GatewayServices;
  • delivery.pySlackDelivery, the ChannelDelivery implementation (including the vendor deep link behind build_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-owned SlackSettings store with a loud, retry-safe migrate_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.

  • The persistent map is core: session_map.py set_channel_link / get_channel_link (generic thread_ts/channel_id keys). 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, ChannelDelivery only.
  • sync_bridge.py hands a dashboard conversation off to a channel thread (handoff_to_channel); voice_reply.py uploads TTS voice replies (upload_voice_to_channel).
  • channel_history.py keeps a rolling per-channel message window (observe_max_messages / observe_ttl_hours — generic top-level config keys).