Solutions
For developers
- 50325
- Local API port
- 11
- Routes on the local API
- 9
- MCP tools
- 7
- API key scopes
- 13
- Webhook events
Your code drives a real profile, not a fresh browser
A script that opens a plain Chromium starts from the machine it runs on. The screen, the fonts, the GPU string and the time zone belong to that computer, and every account you open there repeats them. A user data directory keeps cookies apart, but it cannot give the tenth account a different device.
An ARMANOS profile carries the device with it. Each one holds its own cookies, storage, extensions and proxy, plus its own fingerprint from generation 4 of the generator, built on one of five templates: macos-chrome, windows-chrome, linux-chrome, android-chrome and android-tablet. On the ARMANOS Browser engine, version 153.0.7978.0, those values are substituted inside the engine rather than painted over by a page script that a page script can find.
You attach the way you would to any Chromium: over the DevTools protocol, on loopback. Opening that port normally sets navigator.webdriver to true, which announces automation on the first line of most checks. The engine launches with --disable-blink-features=AutomationControlled, the value stays false, and a test starts the real engine and reads it from a real page.
| What you need | Incognito window | Spoofing extension | Virtual machine | ARMANOS profile |
|---|---|---|---|---|
| Cookies and storage kept apart per account | – | – | Oo | Oo |
| Fingerprint changed below page JavaScript | – | – | Oo | Oo |
| Its own proxy, not the machine's | – | browser-wide | Oo | Oo |
| Ten accounts open at once on a laptop | – | – | as much RAM as you have | Oo |
| Your code attaches to exactly one of them | – | – | Oo | Oo |
| Opens in about a second | Oo | Oo | – | Oo |
The local API lives on your machine and answers only programs
Turn the local interface on in the app, under Settings, API and MCP. The server binds to 127.0.0.1 and to nothing else. The default port is 50325, and if something already holds it, the app takes a free port from the system and reports the one it got.
It is off until you switch it on. On first run the app writes local-api.json into its data directory with a token of 24 random bytes and the switch set to off, readable by your account alone. Send the token as the token query parameter, as the x-api-token header, or as Authorization: Bearer. Regenerating it takes effect at once, with no restart.
Loopback is not a boundary on its own. The pages a person visits also run on this machine, and a domain rebound to 127.0.0.1 reaches a local server as same origin. So a request carrying an Origin header, or a Sec-Fetch-Site other than none, or a Host that is not the address the server listens on, gets 403 and nothing else. That check runs before /status, because /status names the product and its exact version, and no page has business learning that.
Answers use one envelope: code 0 with a data object on success, a negative code and a message on failure. A request body over 1 MB is dropped. A missing or wrong token is 401, an unknown route is 404, and a handler that throws is 500 with the reason.
| Route | Method | What it does |
|---|---|---|
| /status | GET | The only route without a token. Answers ready and the app version. |
| /api/v1/profile/list | GET | Every profile with scenario, group, fingerprint template, proxy summary without passwords, and whether it is open. Also served at /api/v1/browser/list. |
| /api/v1/browser/start | POST | Opens the profile through the same gate as the button in the window. Returns its debugging endpoint. Accepts headless for this run only. |
| /api/v1/browser/stop | POST | Closes every window of that profile and says how many it closed. |
| /api/v1/browser/active | GET | The ids of profiles that have a window open right now. |
| /api/v1/profile/create | POST | Creates a profile through the same licence and quota gate as the app. Returns the new profileId and the profile. |
| /api/v1/profile/delete | POST | Moves the profile to the recycle bin, from where the app can restore it. |
| /api/v1/profile/code | GET | The current six-digit code for a profile that has a 2FA key stored, with the seconds it is still valid for. |
| /api/v1/proxy/list | GET | The saved proxy library with the result of the last reachability check. Passwords are never included. |
| /api/v1/flow/list | GET | The automation flows on this machine and how many steps each one has. |
| /api/v1/flow/run | POST | Runs a flow across the profile ids you name, with a concurrency you choose. |
Every open profile hands you its own debugging endpoint
POST /api/v1/browser/start goes through the same path as the button in the window: the same licence check, the same quota. You may ask for this one run to be headless, in the body or in the query string, and the profile's own setting stays as it was. The strings 0, false and empty count as no, because a query string carries text and a non-empty string is not a yes.
On the ARMANOS Browser the answer is a debugging endpoint that belongs to that profile alone. The engine starts with --remote-debugging-port=0 bound to 127.0.0.1, Chromium picks a free port and writes it, with the websocket path, into DevToolsActivePort inside that profile's directory. The app reads that file, retrying thirty times at 150 ms, and answers with debugPort and a ws://127.0.0.1 address. The endpoint exists only while the profile is open.
On the built-in Electron engine there is one debugging port for the whole app, shared by every profile, and the answer returns ws as null and says so in a note. That port is opened only when the built-in engine is the one running. An unprotected port that nothing uses is a door left open on your own machine, so it is not opened by default.
- 1
Turn the local interface on
Settings, API and MCP. Copy the token from the same screen.
- 2
Ask for the profile
POST /api/v1/browser/start with profileId, and the token in x-api-token.
- 3
Read data.ws from the answer
It names the port and the websocket path of this profile and no other.
- 4
Attach and work
The fingerprint, cookies, extensions and proxy are already in place before your first navigation.

Puppeteer and Playwright attach in two calls, and one thing changes when they do
Two calls and you are in. Ask the local API to start the profile, take data.ws from the answer, and hand it to puppeteer.connect as browserWSEndpoint or to Playwright as connect_over_cdp. The examples on the automation page are written in English and run exactly as they stand.
Now the part we would rather you heard from us. Both clients enable the Runtime and Page domains the moment they attach. Those domains make Chromium install hooks that page scripts can notice, and probes for them are standard equipment in anti-bot tooling. That is a property of the client, not of our engine, and we cannot switch it off from our side.
Our own runner never enables a domain. Runtime.evaluate, Page.navigate, Input and Network all work without enabling anything, and readiness is polled through document.readyState instead of subscribing to lifecycle events. So a sequence driven by the app leaves the same trace as a person clicking.
The builder in the app has sixteen kinds of step: open, wait, click, type, scroll, read, screenshot, key, random pause, repeat, branch, list loop, tab, cookie, raw JavaScript and wait for a request. Repeat, branch and list loop hold nested steps, up to four levels deep, five hundred steps per list and two hundred flows on a machine. Navigation is limited to http, https and about:blank, checked when a flow is saved and again at the moment it runs, because a step can build its address from a variable.
You can start those flows from outside too. GET /api/v1/flow/list returns them with their step counts, and POST /api/v1/flow/run takes a flowId, the profile ids and a concurrency. Ids it does not recognise come back named, so you can see what to fix, and a run that took zero profiles is reported as a failure rather than a success. That last detail matters: reporting success on zero used to make callers retry and open the same profiles twice.
An external client: Puppeteer, Playwright, Selenium
Your language, your libraries, your error handling, attached to one profile over its own endpoint. Enables the Runtime and Page domains when it connects.
The builder inside the app
Sixteen step types, no code and no observable domains. Runs on this machine, started by hand, on a schedule, or through /api/v1/flow/run from your own program.
The two SDKs wrap the server, and deliberately nothing else
Two small clients ship in the repository: packages/sdk-js for Node and packages/sdk-python for Python. The Python one imports only the standard library, because five HTTP requests should not drag a dependency tree into your project. The Node one uses the built-in fetch, so it needs Node 18 or newer, and carries hand-written type definitions.
They wrap the server API, not the app on your machine. The base address defaults to https://api.armanos.io and comes from ARMANOS_API_URL, and the key comes from ARMANOS_API_KEY. They add no logic of their own on purpose: a client that knows more than the server drifts from it within a month and starts to lie.
They check the key before the first request goes out. A key with a stray space or a character pasted in from somewhere else breaks the HTTP header itself, and the failure then reads as the server being down. Instead you get a plain message. Every error carries the HTTP status and a typed code that separates a missing key, a corrupted key, a lost connection, a rejected key and a missing right.
Starting a profile sends a device id that defaults to sdk@hostname. The lock that stops one profile opening twice, and the count of devices on your plan, both need machines to be distinguishable. One shared word for every machine broke both quietly.
Both carry a webhook verifier, and that is the half the receiving side actually needs. It recomputes HMAC-SHA256 over the timestamp, a dot and the raw body, compares in constant time on bytes, and refuses anything older than 300 seconds. Without the age check, one intercepted call would stay correctly signed forever.
- profiles.list() / profiles_list()
- GET /profiles
- profiles.create() / profile_create()
- POST /profiles
- profiles.start() / profile_start()
- POST /profiles/{id}/start
- proxies.update() / proxy_update()
- PATCH /proxies/{id}
- logs.suspicion() / logs_suspicion()
- GET /logs/suspicion
- verifyWebhook() / verify_webhook()
- HMAC-SHA256 over timestamp.body
Every path the two clients call is checked against the generated reference, so a route that changes name fails the build rather than your script.
The MCP server lets an AI client open and close your profiles
The MCP server is a single Node file with no dependencies: apps/desktop/src/mcp/armanos-mcp-server.js. Your client starts it, not the app. It speaks JSON-RPC 2.0 over stdin and stdout, reads both newline-delimited frames and Content-Length framing, and always writes newline-delimited JSON.
It holds no credentials of its own. Two environment variables point it at the local API: ARMANOS_API_URL, which defaults to http://127.0.0.1:50325, and ARMANOS_API_TOKEN. The app builds the whole configuration block for you, with the right path and the right token already in it, and puts a copy button next to it.
Nine tools, each mapped to exactly one route the local API really implements. A tool for a route that does not exist would be worse than no tool at all, because the model would keep trying it.
Three details come straight from real failures. launch_profile waits up to ninety seconds, because the engine opens its debugging port at its own pace and fifteen seconds returned "no answer" while the window was still coming up. A reply larger than 5 MB ends with a clear error instead of a promise that never resolves. Inside a Linux AppImage the script is copied next to your data first, because the path inside the image changes on every launch, and a pasted configuration would break silently at the next restart.
get_status
Confirms the local API is reachable and reports the app version. The only tool that needs no token.
list_profiles
Every profile with scenario, group, template, proxy summary and whether it is running.
get_active_profiles
The ids of profiles with a window open right now.
launch_profile
Opens a profile through the same licence and quota gate as the app, and returns the debug port when there is one.
stop_profile
Closes every window of a profile and reports how many were closed.
create_profile
Creates a profile, optionally with a group, template, tags, notes and a proxy, including an SSH exit.
delete_profile
Moves a profile to the recycle bin, from where the app can restore it.
get_one_time_code
The current two-factor code for a profile that has a key stored, with the seconds left on it.
list_proxies
The saved proxy library and the result of the last check, with passwords left out.
Webhooks push events out, API keys let programs in
An API key is a door into the account without a password, so it is built like one. You create it in the dashboard, choose its rights one by one, and see the secret exactly once. The server keeps a SHA-256 hash and a ten-character prefix, so there is physically nothing to show you a second time. Twenty live keys per account is the ceiling, and a revoked key keeps its row, otherwise the audit trail would hold actions by a key nobody can name.
Rights are checked per endpoint and the default is closed. An endpoint that declares no right is unreachable with a key at all, and the reference says so in plain words; only a human login passes there. Fifteen endpoints are open to keys today, across profiles, proxies, flows and logs. The three refusals are told apart on purpose: 401 for a key that is invalid or revoked, 402 for a real key whose plan has lapsed, 403 for a real key missing a right. A script that cannot tell them apart fixes the wrong thing.
Keys belong to the paid plans: Professional, Business and Enterprise. On Free the server refuses to issue one. The plan is read from the effective entitlement on every request, not from what was true when the key was made, so a month of payment cannot buy a permanent key. The answer is held for a minute, because a program makes dozens of calls a second and two extra database reads on each is a real cost. Blocking an account stops its keys at the same moment.
Webhooks go the other way, and they are managed only by a human logged in with a password. A key that could create a subscription would become a leak that outlives its own revocation. Thirteen events, up to ten subscriptions per account, and the secret is sealed in the database rather than hashed, because it has to sign every body.
Each delivery carries X-Armanos-Event, X-Armanos-Timestamp and X-Armanos-Signature, where the signature is sha256= followed by HMAC-SHA256 over the timestamp, a dot and the exact body you received. Every delivery has its own id, because delivery is at least once and you have to be able to drop a repeat. Fields whose names look like a code, token, secret, password, link or hash never leave: an invitation code is a way in, and the path to your server is not ours to trust.
Sending is defensive. Ten seconds per attempt, three attempts, a pause of one second and then two, and a retry only when the answer could differ next time, meaning 5xx and 429. Redirects are not followed, because one redirect would turn an outside address into an inside one and undo the address check. The destination is resolved and checked before every send, not only when it was saved. Fifteen consecutive failures switch the subscription off with the reason written down, and the dashboard shows the last status, the last error and the count.
profile.create, profile.update, profile.delete
A profile appeared, changed, or went to the recycle bin.
profile.start, profile.stop
A profile was opened or closed on some machine of the workspace.
proxy.create, proxy.update, proxy.delete
The shared proxy pool changed.
apikey.create, apikey.revoke
A key was issued or closed.
team.invite, team.remove
Someone was invited to the workspace or removed from it. The invitation code itself never travels.
plan.change
The plan or the paid period changed.
| Right | What a key holding it may do |
|---|---|
| profiles:read | List profiles with their scenario, group and fingerprint template. |
| profiles:write | Create, edit and delete profiles. |
| browser:run | Take and release the profile lock and record opens and closes. It does not launch a window on the server. |
| proxies:read | List the shared proxy pool. Passwords are behind a separate endpoint that no key can reach. |
| proxies:write | Add, edit and delete proxies in the pool. |
| flows:read | List the flow library of the workspace. Changing it needs a human with a manager role. |
| logs:read | Read the action log, the open and close log, sign-ins with address and country, and the suspicion rules. |
The ceilings are in the code, and so is the reference
One ceiling covers the whole server API: 300 requests per 60 seconds. The sign-in surface is tighter at thirty a minute, with two of its routes at ten and three. Support messages are capped at eight per ten minutes. The counter lives in the memory of the instance.
There is no per-plan rate limit, and we will not pretend otherwise. Your plan decides how many profiles, devices and team seats you get, not how fast you may call. If you drive hundreds of profiles, pacing your own loop is your job, and a fleet that hammers one endpoint will meet the same 300 as everyone else.
The local API on your machine has no rate limit at all. It answers programs running under your own account, and the only ceiling is the 1 MB request body. The MCP server adds one of its own: a reply larger than 5 MB is refused with an explanation, which is better than a tool call that hangs forever.
The reference is generated, never written by hand. The running server publishes it at /docs, built from the same controllers that serve the requests, so it cannot promise a route that does not exist. The note about rights on each endpoint is read from the real annotation in the code rather than from a second list, and an endpoint with no annotation says plainly that keys cannot use it. The document also carries three extra fields: the list of rights, the list of webhook events, and every path a key can reach.
- Server API, all endpoints
- 300 requests / 60 seconds
- Sign-in and password routes
- 30 / minute, two of them 10 and 3
- Support messages
- 8 / 10 minutes
- Local API on your machine
- no limit, 1 MB request body
- MCP reply size
- 5 MB, then a clean error
- API keys per account
- 20 live at a time
- Webhook subscriptions
- 10 per account
- Flows on one machine
- 200, up to 500 steps per list
Read from apps/server/src/app.module.ts, apps/desktop/src/lib/local-api.js, apps/desktop/src/lib/rpa.js and the two server services.
What this does not do
- There is no cloud that opens a browser for you. Profiles run on your machine, through the app. The server API creates, edits, shares and locks profiles, and never launches one.
- API keys are a paid feature. On the Free plan the server refuses to issue one, and a key whose plan has lapsed gets 402 rather than silence.
- The two SDKs are version 0.1.0 and live in the repository. Neither is published to npm or PyPI yet, so today you copy the file into your project.
- We cannot stop an external Puppeteer or Playwright client from enabling the Runtime and Page domains when it attaches. If the site you work with looks for that, build the sequence in the app instead.
- There are no per-plan rate limits. One ceiling of 300 requests a minute applies to everyone, so pacing a large fleet is your own work.
- A key cannot manage webhooks, change the flow library or read a proxy password. Those endpoints declare no right and take a human login only.
How to check
Every claim above sits in a file you can open, and most of them are held in place by a stand that runs the real code.
- The local API answers programs and refuses web pages
- apps/desktop/src/lib/local-api.js · apps/desktop/test/local-api-gate.js
- Opening the debugging port does not set navigator.webdriver
- apps/desktop/src/lib/forkEngine.js · apps/desktop/test/webdriver-flag.js, which launches the real engine and reads the value from a real page
- Our own runner never enables the Runtime or Page domains
- apps/desktop/src/lib/cdp.js, the DETECTION NOTE at the top of the file
- Keys are hashed, scoped, and refused on the free plan
- apps/server/src/api-keys/api-keys.service.ts · apps/server/src/auth/api-key.guard.ts · apps/server/test/api-keys.js
- The reference matches the running server, and both SDKs run against it
- apps/server/src/openapi/openapi.ts · apps/server/test/sdk-and-openapi.js
- Webhook deliveries are signed, retried, and visible when they fail
- apps/server/src/webhooks/webhook-dispatch.service.ts · apps/server/test/webhooks.js
Read next
Automation
Runnable Puppeteer and Playwright examples, and the caveat in full.
What the app can do
The sixteen step types of the builder, and the rest of the window.
The engine
What ARMANOS Browser 153.0.7978.0 changes inside Chromium.
Pricing
Which plans include API keys, seats and devices.
Proof
How the claims on this site are checked, and by what.
Build against it today
The app is free for two profiles, and the local API is part of every plan, including that one.