Local API
uplink api serves a local HTTP control API over a unix socket. It speaks the
same command contract the desktop app and uplink ui use, so anything you can
do in the UI a script or product can do programmatically: declare a tunnel,
read back its public URL, toggle it, start or stop serving, query status. It is
the building-block surface for products that mint dynamic URLs on top of Uplink.
uplink api # serves at ~/.uplink/api.sock until Ctrl-C
uplink api --socket /run/my.sock # custom socket path
The server runs in the foreground; keep it available by running it under your supervisor (a systemd unit, or as a child process of your product). It works alongside everything else — the CLI, desktop app, and a running agent share the same on-disk state, and changes made through the API reconcile onto live tunnels automatically.
Why a unix socket
The socket file is created owner-only (mode 0600), so filesystem permissions
are the authentication: any process that can open it already runs as your user
and could drive the CLI anyway. There is no port to firewall, no token to
manage, and nothing a browser or another local user can reach. Every language
speaks HTTP over a unix socket:
curl --unix-socket ~/.uplink/api.sock \
-H 'content-type: application/json' \
-d '{}' \
http://localhost/api/invoke/list_apps
The URL’s host is ignored — the socket is the address. Treat socket access as full control of the device’s tunnels: don’t loosen the file mode, and don’t place the socket in a world-writable directory.
Wire contract
Every call is POST /api/invoke/<command> with a JSON object body holding the
command’s named arguments (camelCase). Success is 200 with the command’s JSON
result; failure is 400 with a structured body:
{ "code": "login-required", "message": "sign in to continue" }
code is stable (error for generic failures); match on it rather than the
message text. Requests must carry a JSON content-type.
Serving a dynamic URL, end to end
Sign the device in once (interactively with uplink login, or headlessly with
uplink login --device-token-file <path>), then:
API='curl -s --unix-socket ~/.uplink/api.sock -H content-type:application/json'
# 1. Who am I / which account owns new tunnels?
$API -d '{}' http://localhost/api/invoke/account_status
# → { "userId": …, "accounts": [{ "id": "acc_…", … }], "selectedAccountId": "acc_…", … }
# 2. Declare a tunnel for local port 3000. The response is the updated app
# table — including the tunnel's public url.
$API -d '{"spec":{"accountId":"acc_…","name":"preview-42","port":3000}}' \
http://localhost/api/invoke/upsert_app
# → [ { "name": "preview-42", "port": 3000, "url": "https://preview-42-….app.….uplink.computer", … } ]
# 3. Make sure the device is serving (starts the agent if needed; idempotent).
$API -d '{}' http://localhost/api/invoke/start_serving
# 4. Later: tear the URL down again.
$API -d '{"name":"preview-42"}' http://localhost/api/invoke/remove_app
While serving is running, the agent watches the app table, so step 2’s change
also applies on its own within about a second — start_serving just makes it
immediate and guarantees the agent is up.
Commands
The commands below are the stable automation surface. The API dispatches the full command set the desktop app uses, but shapes outside this table follow the product UIs and may change between releases.
| Command | Arguments | Returns |
|---|---|---|
account_status | {} | The logged-in profile (accounts, selectedAccountId, deviceSlug, …), or null when logged out |
list_apps | {} | Every declared tunnel: name, port, url, host, public, enabled, edges, sharedWith, hasAuthToken, … |
upsert_app | {"spec": {…}} | Creates or updates one tunnel; returns the updated app table |
remove_app | {"name": "…"} | Removes the tunnel |
rename_app | {"name": "…", "newName": "…"} | Renames it (the public host changes with it); returns the updated table |
set_sharing | {"app": "…", "email": "…", "grant": true} | Grants/revokes an email on a gated tunnel |
start_serving | {} | Ensures the agent is running and serving the declared table |
stop_serving | {} | Stops serving everything on this device |
serving_status | {} | { "running": bool, "edges": [{ "edge", "phase", "apps", … }] } — phase is connecting / connected / reconnecting / failed |
The upsert_app spec
accountId, name, and port are required. Everything else is optional, and
an omitted field keeps the existing tunnel’s value (new tunnels get the
defaults), so a partial update never resets what you don’t mention:
| Field | Meaning |
|---|---|
public | true opens the tunnel to everyone; default is gated to the owning account |
auth | Sets an access token (stored hashed); "" clears it; omitted keeps it |
sharedWith | Emails granted access to a gated tunnel |
edges | Pin to specific edge ids; omitted picks the nearest region for new tunnels |
allowedIps | Source IP/CIDR allowlist |
rateLimit, bandwidthCapMbps, ruleIds | Request budget, bandwidth cap (0 = uncapped), attached WAF rules |
enabled | Per-tunnel on/off toggle |
inspect | Request capture: off, metadata, or full |
ephemeral | Remove the tunnel when serving stops |
Alternatively: declarative files
If your integration is write-mostly, you may not need the API for mutations at
all: tunnels declared in uplink.yaml are watched while
serving, so editing a config file and letting the agent reconcile is equally
valid — the API’s list_apps/serving_status remain useful for reading back
URLs and health either way.