Chat-controlled screen-time enforcement for iOS. Your phone actually blocks the apps. An LLM decides when to let you back in.
ScreenCP is a self-hosted MCP server plus a native iOS app that holds Apple's Family Controls entitlement. You connect the server to ChatGPT, Claude, or Claude Code, and from then on your screen-time rules live in a conversation rather than in a settings pane you'll disable at 11pm:
You: Block Social until 5. Assistant: (block_now) Social is shielded — applied on your phone.
You: ok give me 10 minutes of Instagram, I need to reply to someone Assistant: (grant_temp_access) 10 minutes on Instagram, starting now. What's the reply about?
The point isn't the blocking — iOS Screen Time already blocks. The point is that the unlock requires talking to something that remembers your goal, asks why, logs the reason, and can say "you've asked three times today." It's a commitment device with a conversation as the door.
Status: works, in daily use by its author, and honestly single-user. Read Limitations before you invest a weekend — the prerequisites are real (paid Apple Developer account, physical device) and the trust model is "you host it for yourself."
ChatGPT / Claude / Claude Code
│ MCP (Streamable HTTP, bearer token)
▼
┌──────────────────────┐ ┌──────────────┐
│ ScreenCP server │◄──────►│ Postgres │
│ Node + TypeScript │ │ (or SQLite) │
│ on Fly.io │ └──────────────┘
└──────────────────────┘
│ APNs push ("something changed, sync")
▼
┌──────────────────────┐
│ iOS app (Swift) │ Family Controls entitlement
│ ManagedSettings + │ → draws the shields
│ DeviceActivity │ → enforces schedules & limits
└──────────────────────┘
▲
│ optional: macOS menu-bar app (userspace enforcement)
The server never enforces anything. It holds intent — groups, policies, grants —
and pushes a nudge when intent changes. The phone syncs, reconciles, and applies.
When the phone hasn't caught up yet, the MCP tools say so out loud (delivery: pending) rather than pretending.
This is the most interesting constraint in the system, and it's Apple's doing.
A group is a named set of apps: "Social", "Games", "TikTok". Every policy and every grant applies to a whole group. You'd expect the server to store a list of app bundle ids per group. It can't.
Apple's FamilyActivityPicker hands your app an opaque ApplicationToken — an
encrypted blob that is meaningless outside your app on that device. There is no API to
ask "which app is this?" You cannot enumerate installed apps, resolve a token to a
name, or reconstruct a selection from anything but the picker itself. That's
deliberate: it means a screen-time app cannot exfiltrate which apps you have or
use, even if its server is compromised or its developer is hostile.
So ScreenCP splits the world:
- The server knows the group's name, its policies, and a boolean
hasSelection. - Only your phone knows which apps are in it. Only you, in the picker, can change that.
- The LLM knows the name and nothing else. It is instructed never to claim otherwise — if you ask "what's in Social?", the honest answer is "I can't see that."
The consequences are real and shape the whole UX:
- Creating a group from chat is a two-step dance: the tool creates it, then tells you to open the app and pick apps. The server sends a tap-through notification to help.
- Want surgical grants? Make a group per app ("Instagram" containing only Instagram). A grant on a narrow group punches through a broader group's block — so "Social" can stay shielded while you get 10 minutes of Instagram.
- That punch-through works for individually-picked apps but not for category-based picks, which are opaque to subtraction. Pick individual apps if you want precision.
- Per-app minute totals on iPhone are impossible. Apple gives you threshold events ("crossed 30 minutes"), not usage numbers. Any iOS app claiming exact per-app time for arbitrary apps is doing something you should be suspicious of. The optional Mac companion can measure exact minutes, because macOS has no equivalent restriction — which is the one place ScreenCP gets real numbers.
14 tools. get_status, list_groups, get_today_summary and get_summary_range are
read-only; the rest mutate.
| Tool | What it does |
|---|---|
get_status |
Every group, active policies, live grants, and per-policy delivery state |
list_groups |
Group names, whether apps are selected, unlock mode, policy count |
create_group |
New empty group (you must then pick its apps on the phone) |
set_schedule |
Recurring block window, e.g. weekdays 09:00–17:00 |
set_limit |
Daily time limit in minutes |
block_now |
Block immediately, optionally until a timestamp |
unblock |
Lift an active block |
grant_temp_access |
Time-boxed access, capped server-side, reason logged |
remove_policy |
Delete a schedule/limit/block |
set_group_mode |
strict (chat-only unlock), quota (N self-serve/day), or open |
set_earn_rule |
Focused Mac minutes buy phone unlocks |
set_goal |
Today's intent, so the coach has context |
get_today_summary |
Shield hits, thresholds crossed, grants + reasons, Mac minutes |
get_summary_range |
Same across multiple days |
Unlock modes are the commitment dial. strict means the assistant is the only
door — you cannot unlock from the phone at all. quota gives you N self-serve unlocks
a day from the shield itself, each requiring a typed reason. open is a speed bump.
The server ships an instructions payload that teaches the model the group-opacity
rule, the punch-through semantics, and how to coach from grant reasons. Most of the
product's behavior lives there, not in the tool schemas.
Roughly an hour, most of it Apple's fault.
- A paid Apple Developer account ($99/yr). The Family Controls entitlement is not available to free accounts. This is the hard gate.
- A physical iPhone, iOS 16+. Screen Time APIs do not work in the Simulator.
- Xcode, XcodeGen (
brew install xcodegen), Node 22+, and the Fly CLI.
Create a Supabase project (or bring any Postgres), grab the direct connection string, and apply the schema:
cd server && npm install
DATABASE_URL='<connection-string>' npm run db:applySQLite works too if you'd rather not run Postgres — set SQLITE_PATH instead of
DATABASE_URL and mount a Fly volume at /data.
cd server
fly launch --no-deploy --copy-config # pick your own globally-unique app name
fly secrets set \
DATABASE_URL='<connection-string>' \
MCP_BEARER_TOKEN="$(openssl rand -hex 32)" \
DEVICE_BEARER_TOKEN="$(openssl rand -hex 32)"
fly deploySave both tokens in a password manager. MCP_BEARER_TOKEN connects your assistant;
DEVICE_BEARER_TOKEN goes into the iOS app. Verify:
curl -s https://<your-app>.fly.dev/healthz # → {"ok":true}- Claude: Settings → Connectors → Add custom connector →
https://<your-app>.fly.dev/mcp, withAuthorization: Bearer <MCP_BEARER_TOKEN>. - ChatGPT: Settings → Apps & Connectors → enable Developer mode → Create.
- Claude Code:
claude mcp add screencp --transport http https://<your-app>.fly.dev/mcp --header "Authorization: Bearer <MCP_BEARER_TOKEN>"
The server also accepts the token in the path (/mcp/<token>) for clients that can't
send custom headers. Prefer the header. Secrets in URLs end up in logs, shell
history, and referrers.
You can drive it now — create groups, set schedules — before the phone exists. Tools
will honestly report no_device_registered.
Register an App Group and request the Family Controls entitlement for your bundle id in the Apple Developer portal first. Then:
cd ios
# Edit project.yml: set DEVELOPMENT_TEAM to your Team ID and replace the
# com.yourname bundle id prefix throughout. Match the App Group in the
# .entitlements files.
# Secrets.swift is gitignored but IS a declared source in project.yml —
# xcodegen will refuse to generate until this file exists.
cp ScreenCP/Sources/Secrets.swift.example ScreenCP/Sources/Secrets.swift
# ...fill in your Fly URL and DEVICE_BEARER_TOKEN
xcodegen generate
xcrun devicectl list devices # find your device's UDID
xcodebuild -project ScreenCP.xcodeproj -scheme ScreenCP \
-destination "platform=iOS,id=<YOUR_DEVICE_UDID>" \
-allowProvisioningUpdates build
xcrun devicectl device install app --device <YOUR_DEVICE_UDID> <path to .app>The .xcodeproj is generated — ios/project.yml is the source of truth. Re-run
xcodegen generate after editing it.
On first launch the app asks for Screen Time authorization, registers with your server, and offers the picker for each group.
Without APNs the phone still syncs, just not instantly. With it, changes land in ~10s. Create an APNs key (.p8) in the developer portal, then:
fly secrets set \
APNS_TEAM_ID='<your-team-id>' APNS_KEY_ID='<key-id>' \
APNS_TOPIC='com.yourname.screencp' APNS_PRODUCTION=false \
APNS_KEY_P8="$(cat AuthKey_XXXX.p8)"Never commit the .p8. .gitignore already blocks *.p8 and server/.apns/.
mac/ is a menu-bar app that registers as another device. Apple's Screen Time stack
is unavailable on macOS (@available(macOS, unavailable) — the entitlement isn't
offered), so it enforces in userspace: it watches for launches and terminates blocked
apps behind a branded overlay. Every Mac screen-time tool does this; it's bypassable by
a determined user, which is fine for a wellbeing tool. In exchange it measures exact
per-app minutes (AFK-excluded) — the numbers iOS won't give you — which is what
set_earn_rule spends.
Being straight with you:
- Single-user by design, and not safe to multi-tenant. Every row is
user_id='default'and no query filters by user. One sharedDEVICE_BEARER_TOKENis compiled into the app binary — it's a master key. This is fine for a tool you host for yourself. If you point two people at one instance, they will read and control each other's data. Making this multi-user means per-device credentials minted at registration and threadinguser_idthrough every query; it is not done. Seedocs/superpowers/specs/2026-07-11-security-review.mdfor the full honest list. - The earn/quota economy trusts the client.
/device/eventsbelieves the reported focused seconds, so a crafted request can forge earned time. The only person you can cheat is yourself — which is precisely the thing a commitment device is supposed to resist. Known, documented, not fixed. - Paid Apple Developer account required. No way around it; Family Controls is gated. Free accounts cannot build this.
- No per-app iPhone usage minutes, ever. Apple's design, not an omission. See above.
- You must open the app to pick apps. Chat can create a group but cannot fill it.
- The Mac blocker has a sub-second race — a blocked app may flash open before it's terminated.
- Not on the App Store, and not intended to be. Build it for yourself.
cd server
npm install
npx vitest run # 74 tests, ~1sThe Postgres integration test self-skips unless DATABASE_URL is set. server/src/domain.ts
holds the pure policy/grant/summary logic and is where most tests bite.
Coding agents should read CLAUDE.md — it carries the Screen Time gotchas that cost
real debugging time (the shield-action vs shield-configuration extension point
identifiers are asymmetric; extension UserDefaults writes need explicit
synchronize(); DeviceActivity rejects intervals under ~15 minutes).
docs/superpowers/ contains the original design specs, build plans, and the security
review — kept as-is, because they explain why more than the code can.
MIT — see LICENSE.