PavlokPager — Technical Architecture¶
Audience: engineers. For the plain-English version see ../plain/README.md.
1. What it is¶
A single-file .NET 10 console app, packaged and installed as a .NET global tool (pavlok). It sends stimuli (vibrate / beep / zap) to a Pavlok wristband by calling the Pavlok API v5 over HTTPS. Its purpose is to be the out-of-band "phone line" an autonomous agent (Claude Code) can pull when it finishes all work or hits a human-only blocker overnight.
There is no server and no daemon. Every invocation is a short-lived process that authenticates, sends one or more HTTP requests, appends to a local log, and exits.
2. C4 — System Context¶
C4Context
title System Context — PavlokPager
Person(claude, "Claude Code", "Autonomous agent running an overnight mission")
Person(andrew, "Andrew", "Wears the wristband, asleep or away")
System(pavlok, "PavlokPager CLI", "Sends stimuli via escalation ladders; enforces safety budget")
System_Ext(api, "Pavlok API v5", "api.pavlok.com — auth + stimulus endpoints")
System_Ext(band, "Pavlok wristband", "Bluetooth-paired to Andrew's iPhone")
Rel(claude, pavlok, "invokes", "process exec: pavlok page --level urgent")
Rel(andrew, pavlok, "one-time setup", "pavlok login / setup")
Rel(pavlok, api, "POST stimulus/send, users/login", "HTTPS + Bearer token")
Rel(api, band, "push", "vendor cloud → phone → BLE")
Rel(band, andrew, "vibrate / beep / zap")
3. C4 — Container / Component¶
C4Component
title Component View — pavlok process
Container_Boundary(cli, "pavlok (console process)") {
Component(main, "Command dispatch", "Main / switch", "Parses argv, routes to a handler")
Component(cmds, "Command handlers", "Setup / Login / SendSingle / Page / ShowLog", "One method per verb")
Component(sender, "SendStimulusAsync", "HTTP", "Retry + backoff; per-request Bearer auth")
Component(store, "Config + Log store", "JSON / JSONL", "~/.pavlok, owner-only perms")
Component(crypto, "Protect / Unprotect", "DPAPI | plain", "Token at rest")
}
System_Ext(api, "Pavlok API v5")
Rel(main, cmds, "routes")
Rel(cmds, sender, "calls (single + each ladder step)")
Rel(cmds, store, "load config, append log, read budget")
Rel(sender, crypto, "resolve token")
Rel(sender, api, "POST", "HTTPS")
4. Command surface¶
| Verb | Handler | Effect |
|---|---|---|
login [email] |
LoginAsync |
POST /users/login, extract user.token, store encrypted. Password via hidden prompt / piped stdin only. |
setup |
Setup |
Store an existing token (hidden prompt / stdin). |
vibe/beep/zap [1-100] |
SendSingleAsync |
One stimulus. zap clamped to maxZap. |
page --reason "" [--level] |
PageAsync |
Run an escalation ladder. Enforces the 8-hour budget. |
test |
→ SendSingleAsync("vibe", 30) |
Connectivity check. |
log [-n N] |
ShowLog |
Print recent history. |
Exit codes: 0 ok · 1 send failed after retries · 2 usage error · 3 page budget refused.
5. Stimulus send — sequence¶
sequenceDiagram
participant C as Claude / user
participant P as pavlok
participant S as Config store
participant A as Pavlok API
C->>P: pavlok zap 60 --reason "..."
P->>S: LoadConfig() (maxZap, ladders)
P->>P: clamp value to maxZap, then to 1..100
P->>S: ResolveApiKey() → env PAVLOK_API_KEY, else Unprotect(config)
loop up to 3 attempts, backoff 2^n s
P->>A: POST /stimulus/send { stimulus: { stimulusType, stimulusValue } }
alt 2xx
A-->>P: 200 OK
P->>S: AppendLog(ok=true)
else 401/403
A-->>P: unauthorized
P->>P: stop (retry won't help)
else transient / network
A-->>P: 5xx / timeout
P->>P: wait, retry
end
end
P-->>C: exit 0/1
6. Escalation ladder & safety budget¶
A ladder is an ordered list of (type, value, delayAfterSeconds) steps. page --level <name> runs the named ladder, sleeping delayAfterSeconds between steps. Ladders live in config and are fully user-editable.
Default ladders:
| Level | Steps |
|---|---|
gentle |
vibe 40 → vibe 60 |
normal |
vibe 50 → beep 60 → vibe 80 → beep 80 |
urgent |
vibe 80 → beep 100 → zap 100 ×5 |
The urgent ladder ends in five max-strength zaps because the wearer is a deep sleeper; it is intentionally not weakened.
stateDiagram-v2
[*] --> ParseArgs
ParseArgs --> Refused: pages in last 8h ≥ maxPagesPer8Hours (and not dry-run)
ParseArgs --> RunLadder: budget available
Refused --> [*]: exit 3, log page-refused
RunLadder --> Step: for each ladder step
Step --> Delay: more steps remain
Delay --> Step: after delayAfterSeconds
Step --> Done: last step
Done --> [*]: exit 0 if any stimulus delivered
Budget: PageAsync counts page-start entries in pages.jsonl within the last 8 hours. At or above maxPagesPer8Hours (default 3) it refuses with exit 3 — a hard stop so a looping agent cannot zap all night. Dry-runs are logged with distinct modes (page-dryrun*) and do not consume budget.
7. Security model¶
| Concern | Mechanism |
|---|---|
| Secrets in transit | HTTPS only; constant host; Bearer token attached per request (shared HttpClient stays stateless). |
| Secrets at rest | Windows: DPAPI (ProtectedData, CurrentUser) → dpapi: blob. Non-Windows: plain: fallback, file locked to owner. |
| Secrets in argv | Never. setup/login read from hidden prompt or piped stdin; only non-secret email may be positional. |
| Response leakage | Raw API bodies are not echoed; only status + reason. Full body gated behind PAVLOK_DEBUG. |
| File perms | ~/.pavlok created 0700; config.json and pages.jsonl created 0600 (UnixCreateMode) from first write — no post-hoc chmod window. |
| Log resilience | A torn/corrupt JSONL line is skipped, so a partial write can't break the budget check. |
Verified clean by Codex OWASP scan and Semgrep SAST (see SECURITY.md): SSRF, TLS, auth-header handling, unsafe deserialization, path traversal.
8. Storage layout¶
%USERPROFILE%\.pavlok\
config.json # { encryptedApiKey, maxZap, maxPagesPer8Hours, ladders{} }
pages.jsonl # one JSON object per line: { time, type, value, mode, reason, ok }
PAVLOK_API_KEY (env) overrides the stored token when set.
9. Build, package, install¶
dotnet pack PavlokPager.csproj -c Release # → nupkg/PavlokPager.<ver>.nupkg
dotnet tool install --global --add-source .\nupkg PavlokPager
Versioning gotcha: dotnet tool update is a no-op if <Version> is unchanged. Bump <Version> on every code change (or uninstall + install), or the global pavlok keeps running the old binary.
10. Deliberate non-goals¶
- No Docker/daemon — a fire-and-forget CLI is the correct shape; there is nothing to keep running.
- No account creation — bring-your-own credentials only (login or paste a token).
- No multi-user/service auth — single local user, single band.