SPEC-0022: Endpoint Presence (Clock In / Clock Out)
SPEC · SPEC-0022 · Status · draft · Date · 2026-09-17 · Implements · ADR-0027 · Requires · SPEC-0003, SPEC-0006, SPEC-0007, SPEC-0011, SPEC-0014
Overview
Every vended endpoint has a presence: whether its agent is taking doorbells right now. An agent clocks out to stop being rung, and clocks in to be rung again. Its human can do the same, or set a standing weekly shift. While an endpoint is out, Switchboard holds its doorbells: todos are still created and can still be pulled, but no push is sent and no heartbeat ring is spent. When the endpoint comes back in, it receives one digest doorbell summarizing what waited. See ADR-0027.
This spec defines presence, the verbs, the operator surface, and the digest. The delivery changes it depends on are amendments to SPEC-0011: REQ "Push Notification Shape" (the digest), REQ "Scope-Filtered Fan-Out" (the presence filter), and REQ "Doorbell Heartbeat" (delivery accounting). The self-verb exemption is an amendment to SPEC-0006 REQ "Scope Enforcement at the Boundary" and SPEC-0007 REQ "Scoped Capability Enforcement".
Terms:
- In / out: the endpoint's effective presence.
- Shift: an optional weekly schedule stored on the endpoint.
- Override: an
inoroutpresence set by the agent or operator, with an optional end. - Boundary: an instant at which the shift's answer changes.
- Held todo: a pending, push-eligible todo on an endpoint that is out.
Requirements
Requirement: Presence Model
Each endpoint MUST carry: an optional shift; an optional override of presence_override (in or
out), override_until (timestamp or null), override_set_at (timestamp; the anchor an override's
end at the next shift boundary is computed from) and override_by (agent or operator:<human_id>);
and presence_seen (the effective presence last acted upon, REQ "Clock-In Digest").
The effective presence at time t MUST be decided, in order:
- an override whose
override_untilis null or aftertand which has not been ended by a boundary (see below) decides; - otherwise, if a shift is set,
inwhentis inside the shift andoutwhen it is not; - otherwise,
in.
An override MUST end at the first shift boundary after it was set, even if override_until is later
or null. Effective presence MUST be computable from stored columns and the clock alone, so every
instance reaches the same answer without coordinating. An endpoint with no shift and no override MUST
behave exactly as before this spec.
Scenario: Default is in
- WHEN an endpoint has no shift and no override
- THEN its effective presence is
in
Scenario: A shift boundary ends an override
- WHEN an endpoint with shift
Mon-Fri 09:00-13:00clocks out at 10:00 Monday with no end time - THEN it is out until 13:00 Monday, out until 09:00 Tuesday by shift, and in at 09:00 Tuesday
Scenario: Override without a shift
- WHEN an endpoint with no shift clocks out with no end time
- THEN it stays out until it clocks in or its operator sets presence
Requirement: Shift Schedule
shift MUST use the weekly-window grammar Harness defines for operating_hours: an optional TZ= or
CRON_TZ= zone prefix, then ;-separated windows of an optional day spec (Mon–Sun, ranges that
may wrap, comma lists) and HH:MM-HH:MM. The start is inclusive, the end exclusive, 24:00 is valid
only as an end, and an end before its start runs into the next day. Membership MUST be decided
on local wall-clock time in the zone, so a DST transition shortens or lengthens a window rather than
skipping or repeating it. The zone database MUST be embedded, so validation does not depend on the
host. A blank, malformed or unknown-zone value, or a window whose start equals its end, MUST be
rejected with invalid_argument naming the field.
Only the endpoint's owning human MAY set or clear a shift. Setting one MUST NOT require a re-vend, and a shift MUST NOT be part of the endpoint's scope.
Scenario: Owner sets a shift
- WHEN the owning human sets
shift = "TZ=America/Los_Angeles Mon-Fri 09:00-13:00"on an active endpoint - THEN the endpoint is in on weekdays from 09:00 to 13:00 Pacific, out otherwise, and its credential and scope are unchanged
Scenario: Invalid shift
- WHEN the owner sets
shift = "Mon 09:00-09:00" - THEN the request fails with
invalid_argumentnamingshift, and the stored shift is unchanged
Requirement: Presence Verbs
Every authenticated endpoint session MUST be able to call three verbs, whether or not they appear in
the endpoint's scope.verbs (REQ "Self Verbs Are Unscoped"):
clock_out {}: set anoutoverride withoverride_by = agent. With a shift, it lasts until the first shift boundary after it was set — the next boundary, whether that boundary is a shift start or a shift end, exactly as REQ "Presence Model" defines and not merely the next shift start. Without one, it has no end.clock_in {for?}:foris an optional duration, default1h, at most8h. When the shift (if any) would put the endpoint out, set aninoverride ending at the earlier of now plusforand the next shift start. Otherwise, clear anyoutoverride.forover8h, zero, negative or unparseable MUST be refused withinvalid_argument.presence {}: returnpresence(in/out),source(shift,agent,operator,default),until(when effective presence next changes, or null),shift(or null), andpending(the endpoint's push-eligible pending todo count).
clock_out and clock_in MUST return the same shape as presence, reflecting the new state. None of
the three verbs MAY accept an endpoint, queue or todo argument. They MUST act only on the caller's own
endpoint, and MUST be idempotent: repeating one changes nothing further.
Scenario: Agent clocks out before stopping
- WHEN an agent with no shift calls
clock_out - THEN the result reports
presence = "out",source = "agent",until = null, and new push-eligible todos on its endpoint ring no session
Scenario: Late clock-in outside a shift
- WHEN an agent with shift
Mon-Fri 09:00-13:00callsclock_in {for: "2h"}at 20:00 Monday - THEN it is in until 22:00 Monday and out again afterwards
Scenario: Clock-in crossing a shift start
- WHEN an agent with shift
Mon-Fri 09:00-13:00callsclock_in {for: "2h"}at 08:00 Monday, a window in which the shift would put it out - THEN it is in until 09:00 Monday, the earlier of 10:00 (now plus
for) and the next shift start, and the shift decides from 09:00 onward
Scenario: Clock-in inside a shift only clears a clock-out
- WHEN an agent with shift
Mon-Fri 09:00-13:00callsclock_outat 10:00 Monday and then callsclock_inat 11:00 Monday - THEN the
outoverride is cleared and it is in for the remainder of the shift, ending at 13:00 Monday when the shift puts it out again
Scenario: Clock-out expires at the next boundary, not the next shift start
- WHEN an agent with shift
Mon-Fri 09:00-13:00callsclock_outat 10:00 Monday - THEN its
outoverride ends at 13:00 Monday, the first boundary after it was set, and is governed by the shift from then until 09:00 Tuesday
Scenario: Clock-in cap
- WHEN an agent calls
clock_in {for: "9h"} - THEN the call fails with
invalid_argumentand presence is unchanged
Scenario: Repeated clock-out
- WHEN an agent that is already out calls
clock_outagain - THEN the result is the same state, and no digest or other doorbell is sent
Requirement: Self Verbs Are Unscoped
clock_in, clock_out and presence are self verbs. The boundary check MUST allow them for any
session authenticated to an active endpoint, MUST advertise them in tools/list for every such
session, and MUST NOT add them to, or require them in, any stored scope.verbs. A self verb MUST NOT
read or change any other endpoint, any todo, or any webhook, and MUST NOT widen what any other verb
may do. A revoked endpoint's sessions are closed (SPEC-0007), so self verbs share revocation with
everything else.
Scenario: Endpoint vended before presence existed
- WHEN an endpoint whose scope lists only
list_todosandclaimcallstools/list - THEN the result lists
list_todos,claim,clock_in,clock_outandpresence, andclock_outsucceeds
Scenario: Self verb cannot target another endpoint
- WHEN a session calls
clock_outwith an extraendpointargument - THEN the call is refused with
invalid_argumentand no endpoint's presence changes
Requirement: Held Doorbells
While an endpoint is out:
- todo creation, dedup, routing and work orders MUST be unchanged;
PublishTodoReadyMUST NOT ring any of its sessions (SPEC-0011 REQ "Scope-Filtered Fan-Out");- the heartbeat sweep MUST NOT select its todos, and MUST NOT change their
ring_attemptsorlast_ringed_at(SPEC-0011 REQ "Doorbell Heartbeat"); - every pull and lease verb (
list_todos,claim,claim_next,heartbeat,complete,fail) MUST behave exactly as when it is in; - the reaper and retry scheduling MUST be unchanged.
An instance MUST observe a presence change made on another instance within 5 seconds of the broadcast, and within 30 seconds in all cases, when the periodic evaluator is the fallback.
Scenario: Todo arrives while out
- WHEN a verified delivery creates a todo on an endpoint that is out, and that endpoint has a session with an open stream
- THEN the todo is
pending, nonotifications/claude/channelis sent, and itsring_attemptsstays 0
Scenario: Pull while out
- WHEN an agent that is out calls
claim_next - THEN it receives a pending todo exactly as it would when in
Requirement: Clock-In Digest
When an endpoint's effective presence changes from out to in (a clock_in, an operator change, a
shift start, or an override ending), Switchboard MUST send one digest doorbell to one session of
that endpoint on each instance that hosts an open-stream session for it and either claimed the
transition or received its broadcast. The digest MUST be sent only
when the endpoint has at least one push-eligible pending todo.
The transition MUST be decided once across all instances and restarts: an instance MUST claim it with
a conditional update of presence_seen from out to in and act only if the update changed a row.
An in to out change MUST update presence_seen the same way, without a digest, so that the next
return is detected. A periodic evaluation (at least every 30 seconds) MUST detect transitions nobody triggered, such as a
shift start or override expiry.
presence_seen MUST advance independently of whether any session could receive the digest, and a
transition whose digest is therefore never sent MUST NOT be re-delivered later. Two rules keep that
from losing the summary:
- An out-to-in transition that no instance can deliver because no instance hosts a session for the
endpoint is recorded and dropped. The endpoint is
inand the return is covered by the reconnect digest, which fires when the first stream opens — so nothing is left unsurfaced. - A hosting instance that misses the broadcast loses that transition's digest for its own session: it cannot claim (the row already moved) and no later evaluation re-delivers it. The summary is not left unsurfaced — the backlog stays on the board, and the next doorbell, or the reconnect digest if the session drops, surfaces it. The cost is one lost digest, not a late one.
- If an instance's conditional update loses the claim (another instance already advanced
presence_seen, including across a restart), it MUST NOT broadcast and MUST NOT send a digest of its own; whether it hosts a session is irrelevant, because the claim is what keeps delivery at-most-once per session, not session locality. An instance that hosts a session for an endpoint whosepresence_seenalready agrees with the effective presence has nothing to deliver for that transition.
An override expiring during an instance's restart is therefore decided by whichever instance next runs the evaluator: the conditional update is on the row, not on process state, so a restart cannot double-decide a transition or skip one it never observed.
The broadcast that tells the other instances MUST be emitted by the claiming instance and by it
alone, after its conditional update has returned a row. An instance that lost the claim MUST NOT
broadcast, or a single transition produces one broadcast per evaluating instance and every hosting
instance sends a digest for each — the "exactly one" property is established by the claim, so the
notification must inherit that single ownership rather than be emitted independently of it. Each
instance that receives an in broadcast and hosts a session for that endpoint emits at most one
digest for it, so a transition yields one digest per hosting instance and never two on one instance.
Sessions of one endpoint each live on exactly one instance, so no session can receive the same
transition's digest twice.
When an endpoint that is in gains its first open notification stream on an instance, and it has at
least one push-eligible pending todo, that instance MUST send a digest with reason reconnect, at
most once per endpoint per 10 minutes per instance.
After sending a digest, the instance MUST set last_ringed_at to the digest time on every todo the
digest counted, without incrementing ring_attempts. Only a digest actually written to a session
marks its todos (REQ "Presence Error Handling and Audit").
The rows marked MUST be the rows counted, and the mark MUST NOT be recomputed from a second, independent read of the backlog. Counting and marking are separated by a network write, so a todo created or claimed in between must not be silently swept into the mark or dropped from it:
- A todo created after the count is not marked, and is rung on its own normal schedule — it was never summarized, so the digest did not tell the agent about it.
- A todo claimed or completed between the count and the mark MUST NOT have
last_ringed_atwritten by the digest, becauselast_ringed_atis only meaningful for apendingrow and the claim already took it out of the sweep's candidate set. - The mark MUST be scoped to the counted ids, not to a queue or endpoint predicate, so a row that arrived in the meantime cannot be marked by accident.
Because the mark sets last_ringed_at without spending an attempt, a todo the digest summarized
waits its normal backoff from the digest time before the sweep considers it again. That is the point:
the digest just told the agent the backlog is there, so re-ringing it one row at a time would spend
exactly the turns presence exists to save. It cannot starve a todo, because the digest is sent only
when the endpoint is in and a session can receive it — the same condition under which the sweep
runs — and every summarized todo's next backoff step still fires. It cannot double-ring a todo
either, because the mark moves last_ringed_at forward, so the sweep's backoff predicate is false
for that todo until the backoff elapses again.
Scenario: Shift start
- WHEN an endpoint with shift
Mon-Fri 09:00-13:00has 7 pending todos at 09:00 Tuesday and one session with an open stream - THEN that session receives exactly one digest with
meta.reason = "shift_start"andmeta.pending = "7", and none of the 7 todos is rung individually until its backoff from 09:00 elapses
Scenario: Two instances see the same transition
- WHEN two instances evaluate the same shift start concurrently
- THEN exactly one claims the transition, only the claimant broadcasts it, and no session receives two digests for it
Scenario: Two instances host sessions for one transition
- WHEN two instances both host a session for one endpoint, and that endpoint's presence goes
from
outtoin - THEN each instance delivers one digest to one of its own sessions, and neither instance's session receives a second digest for the same transition
Scenario: Reconnect after a night off
- WHEN an endpoint with no shift and no override has 3 pending todos, no session all night, and a session opens its stream at 09:00
- THEN that session receives one digest with
meta.reason = "reconnect"andmeta.pending = "3"
Scenario: Reconnect loop
- WHEN a crashing agent reconnects every 30 seconds with pending todos waiting
- THEN it receives at most one reconnect digest per 10 minutes
Scenario: Reconnect digest replaces individual rings
This scenario amends SPEC-0011 scenario "Reconnecting session is rung for waiting work", which describes the behaviour until this spec is implemented.
- WHEN a session opens its notification stream while push-eligible todos in its scope are
pending - THEN switchboard MUST NOT ring those todos individually on the reconnect; the session receives
the one reconnect digest, which sets
last_ringed_aton the counted todos without spending ring budget, and delivery resumes through the sweep and push paths afterward — a reconnect that also charged rings would spend, before the agent knows what is waiting, exactly the turns the digest exists to save
Scenario: Nothing waiting
- WHEN an endpoint clocks in with no pending push-eligible todos
- THEN no digest is sent
Scenario: Digest does not starve or double-ring a todo
- WHEN a digest counts a todo and then marks it
- THEN that todo's
last_ringed_atmoves to the digest time and itsring_attemptsis unchanged, so the sweep waits a further backoff from the digest and does not ring it twice for the same window
Scenario: A todo arriving during the digest is not swept into the mark
- WHEN a todo is created after the digest counted the backlog but before the mark is written
- THEN that todo's
last_ringed_atis unchanged by the digest, and it is rung on its own normal schedule because the digest never summarized it
Scenario: Transition claimed while no session can receive it
- WHEN an endpoint's override expires while it has no session on any instance
- THEN the transition is recorded and no digest is sent, and the return is covered by the reconnect digest when the first stream opens rather than by a retroactive transition digest
Scenario: An override expires during a restart
- WHEN an instance restarts across the moment an
outoverride would have ended, and the evaluator runs again after it comes back - THEN the conditional update on
presence_seendecides the transition exactly once, because the claim is a row update and not process state, and no second digest is produced for it
Requirement: Operator Presence Controls
The operator API MUST expose, under the same OAuth guard as every other /api/v1 route:
GET /api/v1/endpoints/{ref}/presence, returning thepresenceverb's shape;PUT /api/v1/endpoints/{ref}/presencewith{"presence": "in"|"out", "until": RFC3339|null}, setting an override withoverride_by = operator:<human_id>. Unlike the agent'sclock_in, it has no duration cap, and it still ends at the first shift boundary after it was set. With no shift and nountilit has no end and lasts until the operator clears it or sets presence again — the same shape an agent'sclock_outhas, minus the cap on the other direction;DELETE /api/v1/endpoints/{ref}/presence, clearing any override;PUT /api/v1/endpoints/{ref}/shiftwith{"shift": string|null}.
These routes MUST answer not-found for an endpoint the caller does not own (as revoke does) and 409
for a revoked endpoint. The CLI MUST wrap them as switchboard endpoint clock-in REF [--until T],
switchboard endpoint clock-out REF [--until T], switchboard endpoint presence REF, and
switchboard endpoint shift REF [EXPR|--clear].
The board's endpoint card MUST show effective presence, its source, and when it next changes, with a control to clock the endpoint in or out and an editor for the shift. An endpoint that is out MUST NOT be shown with the deaf-consumer warning. The operator's view of held todos MUST say they are held and until when.
Scenario: Operator clocks an agent out
- WHEN the owner sends
PUT /api/v1/endpoints/my-agent-k3x9/presencewith{"presence": "out", "until": null} - THEN the endpoint is out with
source = operator, and the board card shows it as off shift
Scenario: Foreign endpoint
- WHEN a human sends
PUT .../presencefor an endpoint they do not own - THEN the response is not-found and nothing changes
Requirement: Presence Error Handling and Audit
Every presence change MUST record who made it (agent, operator:<human_id>, or shift) and when,
on the endpoint row, and MUST emit a structured log line with the endpoint slug, old and new effective
presence, source, and until. A digest that cannot be written to a session's stream MUST be logged
and dropped under SPEC-0011's lossy rules. Its todos keep last_ringed_at unchanged, because only a
digest actually written marks them. No presence operation may log a credential.
Scenario: Digest write fails
- WHEN a digest write to a session's stream fails
- THEN the failure is logged with endpoint and reason, and the summarized todos'
last_ringed_atis unchanged
Security Requirements
- Self verbs MUST be limited to the caller's own endpoint, and MUST NOT accept identifiers that could address another endpoint (REQ "Self Verbs Are Unscoped").
- An agent-set
inoverride outside a shift MUST be capped at 8 hours, so a prompt injection cannot keep an agent awake indefinitely against its human's shift. - Digest
contentandmetaMUST contain only counts, queue names and reasons: never todo titles, payloads, or anything else derived from a webhook body. - Queue names are operator-defined (an ingestion route or a webhook's target queue, chosen by the
human who wrote the rule), never taken from a delivery body, so they are not attacker-controlled.
They MUST nevertheless be passed through the same neutralization the todo doorbell applies to
meta.todo_id, because a queue name is still operator-supplied text landing inside anotifications/claude/channelframe: a name containing a newline or a</channel>sequence would otherwise be able to break the frame it is quoted in. - The digest MUST NOT be addressed by any caller — it is emitted only by the server for the
endpoint's own transition — so it MUST NOT be usable to read another endpoint's counts. The
pendingandqueuesfigures MUST be computed for the caller's endpoint alone, and the operator routes MUST apply the same ownership check the other/api/v1/endpoints/{ref}routes do, so one human cannot read or change another human's presence or backlog counts. - A prompt injection that makes an agent call
clock_outMUST NOT be able to do more than quiet that endpoint until the next shift boundary or aclock_in: presence cannot refuse a pull, hide work fromlist_todos, move a todo, or widen scope. A repeatedclock_ininjection is bounded by the 8-hour cap and by the shift boundary the override always yields to, so an injected agent cannot hold itself awake past its human's shift. - Operator presence routes inherit the operator OAuth guard, ownership check and rate limits of the
existing
/api/v1/endpoints/{ref}routes.