DocumentationAutomation
Automate with scripts
A script on the same computer can open a profile and take over its browser. The profile it gets is the same one you open by hand — same fingerprint, same connection, same cookies.
What the Local API is
The Local API is a small service inside the app. It answers on this computer only, to requests that carry your key. With it a script can list profiles, start and stop them, and receive the address at which an automation library connects to the started browser.
- Every plan, the free one included.
- This computer only: it listens on
127.0.0.1and cannot be opened to the network. - Off until you turn it on.
- Profiles are made in the app. The API runs the profiles you have; it does not create or edit them.
Turn it on
- Open Settings › API & Automation and turn on Local API. The line beside it changes to Running on this computer only.
- Keep the Port —
48361unless you change it — or press Random free port. - Copy the API key now with Copy: the key itself is never displayed, and once you leave the page it cannot be copied again — only replaced. It is 64 characters long.

- Address
- Where the service answers, with a Copy button — by default
http://127.0.0.1:48361. - Regenerate…
- Makes a new key. The old one stops working at once, and so do the browser addresses given out with it.
- API documentation
- Opens the reference.
Note. The key is kept in the system’s credential store, not in a file. A script should read the address from the file the app writes — Local API/status.json in Liminal’s data folder — instead of hardcoding a port; the reference explains where it is.
The first request
Every request carries the key in a header. Ask the service whether it is up:
curl -H 'Authorization: Bearer <key>' http://127.0.0.1:48361/v1/status{"status":"running","version":"<version>","signedIn":true}signedIn tells you whether somebody is signed in to the app. The operations that touch profiles need that: without it they answer not_signed_in.
Find a profile and start it
List the profiles — the answer comes in pages and can be filtered by name, workspace, folder and status:
curl -H 'Authorization: Bearer <key>' http://127.0.0.1:48361/v1/profilesTake the id of the profile you want and start it:
curl -X POST -H 'Authorization: Bearer <key>' http://127.0.0.1:48361/v1/profiles/<profile-id>/start{
"id": "<profile-id>",
"status": "running",
"cdpDiscoveryUrl": "http://127.0.0.1:48361/devtools/<token>/json/version",
"cdpBrowserUrl": "ws://127.0.0.1:9222/devtools/browser/<token>",
"seleniumDebuggerUrl": "127.0.0.1:9222",
"chromedriverPath": "/Applications/Liminal.app/.../chromedriver",
"debugPort": 9222,
"debugAddress": "127.0.0.1:9222",
"externalIp": "203.0.113.10",
"countryCode": "DE"
}cdpBrowserUrl- The address Puppeteer and Playwright connect to.
seleniumDebuggerUrl,chromedriverPath- What Selenium needs: the debugger address and the driver that matches the browser, shipped with the app.
externalIp,countryCode- What the profile’s connection looked like when it started.
The app starts the profile as if you had pressed Open. A profile that is already open is not started twice — the reference describes how to attach to a running one.
Connect your library
Each example is whole: it reads the address file, finds a profile by name, starts it and opens a page.
import { readFileSync } from 'node:fs'
import puppeteer from 'puppeteer-core'
// Local API/status.json, inside Liminal's user-data directory. The settings
// screen shows the absolute path; that path and the key are your script’s to
// keep — the app puts neither into the environment for you.
const { address } = JSON.parse(readFileSync(process.env.LIMINAL_ADDRESS_FILE, 'utf8'))
async function call(path, init) {
const response = await fetch(address + path, {
...init,
headers: { Authorization: `Bearer ${process.env.LIMINAL_API_KEY}` },
})
const body = await response.json()
if (!response.ok) throw new Error(body.error.code)
return body
}
const { items } = await call('/v1/profiles?name=' + encodeURIComponent('Shop EU'))
const started = await call(`/v1/profiles/${items[0].id}/start`, { method: 'POST' })
const browser = await puppeteer.connect({ browserWSEndpoint: started.cdpBrowserUrl })
const page = await browser.newPage()
await page.goto('https://example.com')
console.log(await page.title())
await browser.disconnect()import { readFileSync } from 'node:fs'
import { chromium } from 'playwright'
// Local API/status.json, inside Liminal's user-data directory. The settings
// screen shows the absolute path; that path and the key are your script’s to
// keep — the app puts neither into the environment for you.
const { address } = JSON.parse(readFileSync(process.env.LIMINAL_ADDRESS_FILE, 'utf8'))
async function call(path, init) {
const response = await fetch(address + path, {
...init,
headers: { Authorization: `Bearer ${process.env.LIMINAL_API_KEY}` },
})
const body = await response.json()
if (!response.ok) throw new Error(body.error.code)
return body
}
const { items } = await call('/v1/profiles?name=' + encodeURIComponent('Shop EU'))
const started = await call(`/v1/profiles/${items[0].id}/start`, { method: 'POST' })
const browser = await chromium.connectOverCDP(started.cdpBrowserUrl)
const page = await browser.contexts()[0].newPage()
await page.goto('https://example.com')
console.log(await page.title())
await browser.close()import json
import os
import urllib.parse
import urllib.request
from playwright.sync_api import sync_playwright
# Local API/status.json, inside Liminal's user-data directory. The settings
# screen shows the absolute path; that path and the key are your script’s to
# keep — the app puts neither into the environment for you.
with open(os.environ["LIMINAL_ADDRESS_FILE"], encoding="utf-8") as handle:
address = json.load(handle)["address"]
def call(path, method="GET"):
request = urllib.request.Request(address + path, method=method)
request.add_header("Authorization", "Bearer " + os.environ["LIMINAL_API_KEY"])
with urllib.request.urlopen(request) as response:
return json.load(response)
profile = call("/v1/profiles?name=" + urllib.parse.quote("Shop EU"))["items"][0]
started = call(f"/v1/profiles/{profile['id']}/start", method="POST")
with sync_playwright() as playwright:
browser = playwright.chromium.connect_over_cdp(started["cdpBrowserUrl"])
page = browser.contexts[0].new_page()
page.goto("https://example.com")
print(page.title())import json
import os
import urllib.parse
import urllib.request
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
# Local API/status.json, inside Liminal's user-data directory. The settings
# screen shows the absolute path; that path and the key are your script’s to
# keep — the app puts neither into the environment for you.
with open(os.environ["LIMINAL_ADDRESS_FILE"], encoding="utf-8") as handle:
address = json.load(handle)["address"]
def call(path, method="GET"):
request = urllib.request.Request(address + path, method=method)
request.add_header("Authorization", "Bearer " + os.environ["LIMINAL_API_KEY"])
with urllib.request.urlopen(request) as response:
return json.load(response)
profile = call("/v1/profiles?name=" + urllib.parse.quote("Shop EU"))["items"][0]
started = call(f"/v1/profiles/{profile['id']}/start", method="POST")
options = webdriver.ChromeOptions()
options.debugger_address = started["seleniumDebuggerUrl"]
driver = webdriver.Chrome(service=Service(started["chromedriverPath"]), options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()import { readFileSync } from 'node:fs'
import { Builder } from 'selenium-webdriver'
import chrome from 'selenium-webdriver/chrome.js'
// Local API/status.json, inside Liminal's user-data directory. The settings
// screen shows the absolute path; that path and the key are your script’s to
// keep — the app puts neither into the environment for you.
const { address } = JSON.parse(readFileSync(process.env.LIMINAL_ADDRESS_FILE, 'utf8'))
async function call(path, init) {
const response = await fetch(address + path, {
...init,
headers: { Authorization: `Bearer ${process.env.LIMINAL_API_KEY}` },
})
const body = await response.json()
if (!response.ok) throw new Error(body.error.code)
return body
}
const { items } = await call('/v1/profiles?name=' + encodeURIComponent('Shop EU'))
const started = await call(`/v1/profiles/${items[0].id}/start`, { method: 'POST' })
const driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(new chrome.Options().debuggerAddress(started.seleniumDebuggerUrl))
.setChromeService(new chrome.ServiceBuilder(started.chromedriverPath))
.build()
await driver.get('https://example.com')
console.log(await driver.getTitle())
await driver.quit()Tip. Connect to the browser the profile already has. Do not let the library launch a browser of its own — that one would have none of the profile’s fingerprint or cookies.
Stop the profile
curl -X POST -H 'Authorization: Bearer <key>' http://127.0.0.1:48361/v1/profiles/<profile-id>/stopStopping closes the profile the way Close does. There is also a forced stop for a profile that does not close.
Good to know
| Question | Answer |
|---|---|
| Does a script see a different browser than I do? | No. The fingerprint and the connection are applied exactly as when you open the profile by hand, and the same protections hold. |
| Do sites see that the browser is automated? | The usual signs of automation are masked for a profile started through the API. This cannot be turned off. |
| Is there a headless mode? | No. The API starts a profile the same way the app does. |
| Is there a limit on requests? | No quota is tied to a plan. |
| Is there a command-line tool, or recorded scenarios? | No. The Local API is the way to automate. |
| What do I get when something is wrong? | A status and a short code to branch on — invalid_key, not_signed_in, profile_already_running and others. They are listed per operation in the reference. |
Checked against the released app: October 2026. If the app does something else than this page says, the page is wrong — write to [email protected]. What changed in each version is in the changelog.