stacks/uplink
publicClone
Push over the same URL. A password will not work: create a token under access tokens and use it in place of one.
Text your Mac, even over satellite, and Claude Code answers.
- .devcontainer
- .github
- app
- bench
- cloud
- config
- database
- docs
- locales
- public
- resources
- routes
- scripts
- storage
- tests
- .dockerignore 161 B
- .editorconfig 147 B
- .env.example 6.3 KB
- .env.production 5.9 KB
- .gitattributes 922 B
- .gitignore 6.2 KB
- AGENTS.md 26.6 KB
- bootstrap 4.1 KB
- buddy 8.7 KB
- bun.lock 90.4 KB
- bunfig.toml 1.9 KB
- CHANGELOG.md 19 B
- deps.yml 317 B
- LICENSE.md 1.1 KB
- package.json 4.7 KB
- README.md 8.5 KB
- tsconfig.json 1.5 KB
Uplink
Text your Mac from anywhere, even over satellite with no data, and an agent answers.
Newer iPhones can send iMessages over satellite when there is no cell or Wi-Fi signal, but nothing that needs the internet works. Uplink turns those texts into a way to reach a coding agent: it watches Messages on this Mac for texts from you, runs each one through Claude Code or Codex (on the plan you already have, no API key), and texts the answer back.
Claude Code is the default. UPLINK_ENGINE=codex changes it for the whole installation, the
menubar has a picker, and texting codex or claude switches one thread.
- "Whats the NFL score rn?" gets a web search and a two-line answer.
- "please improve ~/Code/stacks with feature xyz" runs a full agent session inside
~/Code/stacks, with that repo's agent instructions, hooks and settings, and texts you a summary. - Follow-ups continue the same conversation until you text
new.
How it works
iPhone --(iMessage, maybe via satellite)--> Messages on this Mac --> ~/Library/Messages/chat.db
|
Uplink.app polls it every 2s (Full Disk Access)
|
claude -p ... or codex exec resume ... (in the named repo)
whichever this thread is set to
|
iPhone <------------------------- Messages (AppleScript) <------- the reply, as plain text- Who can command it: only direct (never group) chats with an allowed handle. By default that is
this Mac's own Messages handles, so you text yourself.
UPLINK_ALLOWEDsets it explicitly. A message is honored only if it passesapp/Uplink/filter.ts; everything else is ignored. - One task at a time per thread. A second task queues.
status,stop,new,more,ping,help,claudeandcodexare answered immediately and never queue. - Built for a thin link: plain text, short answers, long replies split into up to 3 texts with
the rest behind
more, a "Working on it" after 20s and a progress line every 10 minutes. - Replies start with 🛰, so they stand apart in a thread with yourself, and so Uplink never mistakes its own reply for a new command.
- Runs are recorded in the
runstable (cost in integer cents, at API rates; on Max nothing is billed per run) and shown on the dashboard.
Install
Download Uplink for Mac
(macOS 13 or later, signed and notarized). Drag it to Applications and open it: the menubar walks you
through Full Disk Access, signing in to the agent you picked, and who may text it. Then text
yourself ping.
To check what you downloaded, compare it against the digest on the release page, which is also in
resources/data/release.json:
shasum -a 256 ~/Downloads/Uplink.dmgYou need one of the two CLIs and a plan for it:
- Claude Code, the default. The menubar takes a
long-lived token from
claude setup-tokenand keeps it in your Keychain. - Codex. Sign in once on the Mac with
codex login, orcodex login --device-authover SSH. Codex keeps its own credentials, so Uplink stores nothing and needs no API key either.
Uplink answers texts once it is activated: $1.99 a month, $19.99 a year, or $29.99 once, and a code gets six months of Monthly free. The thank-you page has an Activate Uplink button that opens the app with the key filled in. Subscriptions are managed in Stripe's customer portal, from Manage in the menubar. A licensed Mac keeps answering for two weeks without reaching the license server, so it works off-grid.
Licensing, for maintainers
- Plans, the six-months-free coupon (
SIXMONTHS) and the customer portal are declared inconfig/saas.tsfromapp/Billing/plans.ts;./buddy stripe:setupmakes Stripe match. /checkout/<plan>starts Stripe Checkout (app/Billing/checkout.ts); a code is honoured on Monthly only./thanksissues the license (app/Billing/licenses.ts, theLicensemodel).- The app calls
POST /api/license/checkandPOST /api/billing/portal(routes/api.ts), served by theapisite inconfig/cloud.ts.
Build from source
The same app, run from this repository as a launchd service (bundle id com.stacksjs.uplink.source, so
its Full Disk Access grant is separate from the downloaded app's):
Get it and install the background service.
uplink:installcreates.envand the database if they are missing, buildsstorage/uplink/Uplink.appand starts it at login:git clone https://github.com/stacksjs/uplink && cd uplinkbun install && ./buddy uplink:installGrant Full Disk Access to Uplink (source) in System Settings > Privacy & Security.
Sign in to the agent you want. For Claude Code, a long-lived token, because a background service cannot answer a browser prompt:
claude setup-token./buddy env:set CLAUDE_CODE_OAUTH_TOKEN "$(pbpaste | tr -d '[:space:]')"For Codex, sign in once and it keeps its own credentials:
codex login # or: codex login --device-auth, over SSH./buddy env:set UPLINK_ENGINE codexCheck it, then text yourself
ping:./buddy uplink:doctor
Only run one of the two: they share a launchd label, and the downloaded app stops the source service when it starts.
Texting yourself over satellite
Test this once before relying on it. A text to your own number can be delivered to your other
devices without going through the satellite link at all, so confirm the Mac receives a
self-text sent while in satellite mode. If it does not, use a second Apple ID: sign Messages on
this Mac into it, and set UPLINK_ALLOWED to your own phone number.
Pushing to main deploys https://uplink.stacksjs.com once CI passes (.github/workflows/deploy.yml).
Commands
| Command | What it does |
|---|---|
./buddy uplink:install [--rebuild] | Build Uplink.app and start it at login. --rebuild recompiles it, which voids its Full Disk Access grant |
./buddy uplink:doctor | Check every requirement and say how to fix what is missing |
./buddy uplink:restart | Restart now, e.g. after granting a permission. Editing .env restarts it on its own once no run is in progress |
./buddy uplink:uninstall | Stop it and remove it from login |
./buddy uplink:ask "<prompt>" | Run one prompt exactly as a text would, without Messages |
./buddy uplink:release | Build, sign and notarize Uplink.app and publish it to GitHub Releases |
./buddy uplink:watch | The watcher itself, in the foreground (needs Full Disk Access for your terminal) |
./buddy dev | Serves the dashboard at https://uplink.localhost/dashboard and the docs at /docs. Both are local only: the dashboard answers 404 in production |
Configuration lives in .env; see the UPLINK_* block in .env.example, which covers both
engines (UPLINK_ENGINE, UPLINK_CLAUDE_BIN, UPLINK_CODEX_BIN and the rest). The service picks
up edits by itself.
Layout
app/Uplink/: the daemon (uplink.ts), the chat.db reader (messages-db.ts,typedstream.ts), the command filter (filter.ts), the words it answers itself (commands.ts, includingclaudeandcodex), the two engines and the choice between them (engine.ts,codex-engine.ts,engines.ts), replies (sender.ts,format.ts), the launchd service and app bundle (service.ts,launcher.ts) and setup checks (doctor.ts).app/Commands/Uplink.ts: thebuddy uplink:*commands.app/Models/:Conversation(a thread, its agent session and which agent),Run(one text, one run) andLicense(a key, its plan and its Stripe ids).app/Billing/: Stripe Checkout, licences and the plans they come from.resources/views/index.stx: the public page at https://uplink.stacksjs.com.resources/views/dashboard.stx: the dashboard, local only. It callsnotFound(), so it answers 404 wherever the site is deployed.docs/: the documentation, served at /docs bybuddy dev.tests/unit/: against a real chat.db-shaped SQLite file and stub CLIs.
./buddy test