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
- Flexibility to have other indicators in the future (calls, viewing, listening, etc)
- Show general online/away/offline availability
- Track last-seen timestamps
- Allow users to opt-out (go unseen) at any level of either mechanism
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]
+$ presence (map ship (map topic status)) :: ship β topic β status+$ action
$% [%set who=ship topic=@tas context=path timeout=(unit @dr)] :: set status (resets timeout)
[%clear who=ship 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 |
| call | β* | In voice call (clear on end) |
| listening | 30s | Listening to radio/audio |
:: for the %presence agent
/v1/all :: firehose
:: optional but maybe useful? probably don't include until we need
/v1/[topic]/[...context] :: all ships for topic in context (e.g. /v1/typing/chat/~zod/general)
/v1/ship/[ship] :: all statuses for a specific ship
/v1/context/[...context] :: all topics for a context%chat integration: For DMs, we'd like want to add either a standalone poke, or modify DM actions to include a "typing" action so it would become something like:
:: ...
+$ diff
$: [%typing parent-id=(unit id)] :: parent-id included when in a thread
diff:writs
==
+$ action (pair ship diff)
:: ...Upon receiving the new typing action, %chat would then poke %presence:
=/ =action:presence [%set src.bowl %typing /dm/~sampel-palnet `~s10]
(emit [%pass /presence %agent [our.bowl %presence] %poke presence-action+!>(action)])%channels integration: Similar to %chat except that the channel host would be responsible for fanning out the updates. We would add a new command and corresponding update:
:: ...
+$ c-channel
$: :: ...existing actions
[%typing parent-id=(unit id-post)] :: parent-id included when in a thread
==
:: ...
+$ u-channel
$: :: ...existing actions
[%typing who=ship parent-id=(unit id-post)]
==%channels-server simply turns the command into an update, applying the src.bowl to the who then sends through the normal distribution channel.
Upon receiving the new typing update, %channels would poke %presence
?> ?=(%typing -.u-channel)
=/ =action:presence [%set who.u-channel %typing nest.u-channels `~s10]
(emit [%pass /presence %agent [our.bowl %presence] %poke presence-action+!>(action)])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());
}
}Needs work, this suggests %settings-store but maybe we'd want to store each in their respective agents instead?
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