Skip to content

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.