Working directory: ~/Projects/homestead
Author: ~nocsyx-lassul
Date: 2026-01-24
Status: Draft
A two-part presence system for Tlon that provides both ephemeral status indicators (typing, viewing, in-call) and persistent availability states (online, away, offline). Designed to feel alive while respecting user privacy through opt-out controls.
- Show when someone is typing in a chat
- Indicate who's currently viewing a channel
- Display call/listening status
- Show online/away/offline availability
- Track last-seen timestamps
- Allow users to opt-out (go unseen) at any level
Two complementary systems with distinct purposes:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PRESENCE SYSTEM β
βββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ€
β EPHEMERAL (seconds) β AVAILABILITY (hours) β
βββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β %presence agent (NEW) β %contacts agent (EXISTING) β
β β’ typing indicators β β’ online/away/offline β
β β’ viewing status β β’ last-seen timestamp β
β β’ call/listening β β’ custom status message β
β β’ local storage only β β’ profile field sync β
β β’ routed via %chat/%groups β β’ direct profile queries β
β β’ auto-timeout (seconds) β β’ user-controlled updates β
βββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ
Handle short-lived, context-specific presence like typing indicators. Local-only storage with network routing delegated to %chat and %groups.
%presence only accepts self-pokes. It never talks to the network directly.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Network Flow: Typing Indicator β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β [Their Ship] [My Ship] β
β βββββββββββββββ βββββββββββββββ β
β β %chat β ββββ network βββ β %chat β β
β β (sends) β β (validates)β β
β βββββββββββββββ ββββββββ¬βββββββ β
β β β
β β local poke β
β β β
β βββββββββββββββ β
β β %presence β β
β β (stores) β β
β ββββββββ¬βββββββ β
β β β
β β subscription update β
β β β
β βββββββββββββββ β
β β UI/Client β β
β β (displays) β β
β βββββββββββββββ β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+$ topic @tas :: typing, viewing, call, listening
+$ context path :: where (e.g. /chat/~zod/general, /call/~zod/room-1)
+$ status [=topic =context expires=@da] :: topic + context + expiry
+$ presence (map ship (map topic status)) :: ship β topic β statusContext examples:
/chat/~sampel-palnet/generalβ typing in a chat channel/notebook/~sampel-palnet/blogβ viewing a notebook/call/~sampel-palnet/room-1β in a voice call/groups/~sampel-palnet/my-group/settingsβ viewing group settings
+$ action
$% [%set topic=@tas context=path] :: set status (resets timeout)
[%clear topic=@tas context=path] :: explicitly clear
[%clear-all context=path] :: clear all for context
[%heartbeat ~] :: process expired statuses
==| Topic | Timeout | Description |
|---|---|---|
| typing | 10s | Typing indicator |
| viewing | 5min | Active in channel |
| call | β* | In voice call (clear on end) |
| listening | 30s | Listening to radio/audio |
*call status cleared explicitly, not via timeout
/presence/[topic]/[...context] :: all ships for topic in context (e.g. /presence/typing/chat/~zod/general)
/presence/ship/[ship] :: all statuses for a specific ship
/presence/context/[...context] :: all topics for a context
/presence/all :: firehose (admin/debug)%chat integration:
:: in %chat's message handler, when receiving typing from network:
=/ valid (validate-sender src.bowl our.bowl msg)
=/ ctx /chat/(scot %p host.nest)/(scot %tas name.nest)
?: valid
(poke-self %presence [%set %typing ctx])
~%groups integration:
:: similar pattern for group contexts
=/ ctx /groups/(scot %p host.flag)/(scot %tas name.flag)/settings
(poke-self %presence [%set %viewing ctx])desk/
βββ app/presence.hoon :: agent implementation
βββ sur/presence.hoon :: types and structures
βββ mar/presence/
β βββ action.hoon :: mark for actions
β βββ update.hoon :: mark for subscription updates
βββ lib/presence.hoon :: helper functions (optional)
Longer-lived availability states that sync between ships via existing profile infrastructure.
Leverage existing %contacts profile sync. No new agent neededβjust new fields.
Add to existing contact profile structure:
:: in sur/contacts.hoon, extend +$ contact
+$ contact
$: nickname=@t
bio=@t
status=@t :: existing: custom status text
avatar=(unit @t)
cover=(unit @t)
color=@ux
:: NEW FIELDS
availability=@t :: "online" | "away" | "offline" | "dnd" | ""
last-seen=(unit @da) :: when last active (null if private)
presence-mode=@t :: "visible" | "last-seen-only" | "hidden"
==| State | Meaning | Display |
|---|---|---|
| online | Active now | π’ Green dot |
| away | Idle/stepped away | π‘ Yellow dot |
| offline | Not connected | β« Gray dot |
| dnd | Do not disturb | π΄ Red dot |
| "" | Not sharing (opted out) | No indicator |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Availability Sync Flow β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β [My Ship] [Their Ship] β
β β
β Client sets availability Profile query returns β
β β availability + last-seen β
β β β β
β βββββββββββββββ ββββββββ΄βββββββ β
β β %contacts β ββ profile ββββ β %contacts β β
β β (my ship) β sync β (their) β β
β βββββββββββββββ βββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Client-side logic (not in agent):
// Pseudocode for client availability management
const IDLE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
const OFFLINE_THRESHOLD = 30 * 60 * 1000; // 30 minutes
let lastActivity = Date.now();
function updateAvailability() {
const idle = Date.now() - lastActivity;
if (idle < IDLE_THRESHOLD) {
setAvailability('online');
} else if (idle < OFFLINE_THRESHOLD) {
setAvailability('away');
} else {
setAvailability('offline');
}
// Also update last-seen unless hidden
if (presenceMode !== 'hidden') {
setLastSeen(Date.now());
}
}Users should have granular control over what they share. Default to visible, but make opting out easy and respected.
+$ presence-mode
$? %visible :: show availability + last-seen + ephemeral
%last-seen-only :: show last-seen only (no live status)
%hidden :: completely invisible
==In addition to the global mode, users can toggle individual features:
| Setting | Default | Description |
|---|---|---|
| share-availability | true | Show online/away/offline |
| share-last-seen | true | Show when last active |
| share-typing | true | Send typing indicators |
| share-viewing | true | Show when viewing a channel |
| show-others-availability | true | See others' availability (mutual) |
| show-others-typing | true | See others' typing indicators |
If implemented, users who opt out of sharing don't see others' status either:
If I hide β I don't see others
If I share β I see others (who share)
Store in %settings-store under presence namespace:
/apps/groups/presence/share-availability
/apps/groups/presence/share-last-seen
/apps/groups/presence/share-typing
/apps/groups/presence/share-viewing
/apps/groups/presence/modeLocation: Settings β Privacy β Presence
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Privacy β Presence β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Presence Mode β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β Visible Show everything β β
β β β Last-seen Only show when I was last active β β
β β β Hidden Don't share any presence info β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β Individual Controls (when not Hidden) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β [β] Share typing indicators β β
β β [β] Share viewing status β β
β β [β] Share online/away/offline β β
β β [β] Share last-seen time β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β Note: If you hide your presence, you won't see others' either. β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Chat input area:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ~sampel-palnet is typing... β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β [ ] [Send] β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Multiple people:
~sampel-palnet and ~master-botnet are typing...
~sampel-palnet and 2 others are typing...
Animation: Three bouncing dots (...) or subtle pulse
Sidebar or header:
ββββββββββββββββββββββββββββββββββββββββββββββββ
β #general π 3 viewing β
ββββββββββββββββββββββββββββββββββββββββββββββββ€
β Viewing now: β
β β’ ~sampel-palnet β
β β’ ~master-botnet β
β β’ ~random-person β
ββββββββββββββββββββββββββββββββββββββββββββββββ
In member lists and DMs:
ββββββββββββββββββββββββββββββββββ
β DIRECT MESSAGES β
β ββββββββββββββββββββββββββββββ
β β π’ ~sampel-palnet β β online
β β π‘ ~master-botnet β β away
β β β« ~random-person β β offline
β β ~hidden-person β β opted out (no dot)
β ββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββ
Profile card / DM header:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ~sampel-palnet β
β π’ Online β’ Last seen: just now β
β Status: "Building presence features" β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
When hidden:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ~hidden-person β
β Last seen: β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
In channel when call active:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β #voice-chat π 3 in call β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β In voice: β
β β’ ~sampel-palnet (speaking) β
β β’ ~master-botnet β
β β’ ~random-person (muted) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Backend:
- Create %presence agent (
app/presence.hoon,sur/presence.hoon) - Add new fields to %contacts profile structure
- Implement %chat integration for routing presence pokes
- Add %settings-store entries for privacy settings
Frontend:
- Subscribe to %presence updates in relevant views
- Add typing indicator component to chat input
- Add availability dot component
Backend:
- Add %groups integration for viewing status
- Implement heartbeat timer for expiring statuses
- Add privacy mode enforcement
Frontend:
- Typing indicators in chat view
- Availability dots in DM list and member lists
- Profile card availability display
- "X is typing" text above input
Backend:
- Wire up privacy settings to filter what's shared/shown
- Implement mutual visibility logic (optional)
Frontend:
- Privacy settings UI in Settings β Privacy
- Respect hidden users in UI (no placeholder, just absent)
- "X viewers" in channel header
- Animations for typing indicator
- Performance optimization (batch updates)
- Edge cases (reconnection, stale data)
- Mobile parity
-
Mutual visibility: Should hiding your status also hide others from you? (Suggested: yes, for simplicity and fairness)
-
Per-channel/group settings: Should users be able to hide presence in specific channels only? (Suggested: defer to v2)
-
Bot/agent presence: Should Clawdbot and similar agents show typing? (Suggested: yes, opt-in for agents)
-
Read receipts: Out of scope for this PRD, but related. Consider separately.
-
Presence in public channels: Should viewing be visible in large public channels? (Suggested: show count only, not names, above threshold)
- Typing indicators appear within 500ms of user starting to type
- Availability updates propagate within 5s
- <1% false positives on typing (showing when not typing)
- Privacy settings respected 100% of the time
- No presence data leaks for hidden users
|%
+$ topic ?(%typing %viewing %call %listening)
+$ context path :: flexible context path
+$ status [=topic =context expires=@da]
+$ presence (map ship (map topic status))
::
+$ action
$% [%set =topic =context]
[%clear =topic =context]
[%clear-all =context]
[%heartbeat ~]
==
::
+$ update
$% [%set =ship =status]
[%clear =ship =topic =context]
[%full =presence]
==
::
+$ mode ?(%visible %last-seen-only %hidden)
--/+ default-agent, dbug
|%
+$ versioned-state $%(state-0)
+$ state-0 [%0 =presence:sur mode=mode:sur timeouts=(map topic:sur @dr)]
+$ card card:agent:gall
--
%- agent:dbug
^- agent:gall
|_ =bowl:gall
+* this .
def ~(. (default-agent this %.n) bowl)
::
++ on-init
^- (quip card _this)
=/ defaults (malt ~[[%typing ~s10] [%viewing ~m5] [%call *@dr] [%listening ~s30]])
[~ this(state [%0 ~ %visible defaults])]
::
++ on-poke
|= [=mark =vase]
^- (quip card _this)
?> =(src.bowl our.bowl) :: only self-pokes
?+ mark (on-poke:def mark vase)
%presence-action
=/ act !<(action:sur vase)
?- -.act
%set
:: update state, kick subscribers
=/ expires (add now.bowl (~(got by timeouts.state) topic.act))
=/ new-status [topic.act context.act expires]
:: ... update presence map, send updates
[~ this]
::
%clear
:: remove from state, kick subscribers
[~ this]
::
%clear-all
:: clear all for context
[~ this]
::
%heartbeat
:: expire old entries
[~ this]
==
==
::
++ on-watch
|= =path
^- (quip card _this)
?+ path (on-watch:def path)
[%presence %topic @ *]
:: /presence/topic/[topic]/[...context]
[~ this]
::
[%presence %ship @ ~]
:: /presence/ship/[ship]
[~ this]
::
[%presence %context *]
:: /presence/context/[...context]
[~ this]
==
::
++ on-agent on-agent:def
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?+ wire (on-arvo:def wire sign-arvo)
[%heartbeat ~]
:: process timer, set next
[~ this]
==
++ on-peek on-peek:def
++ on-leave on-leave:def
++ on-fail on-fail:def
--- Telegram: Online status + last seen + typing
- Discord: Online/idle/dnd + typing + viewing voice
- Slack: Active/away + typing + huddle presence
- Signal: Typing only (privacy-focused)
- WhatsApp: Online/last-seen + typing + read receipts